@atscript/ui-table 0.1.137 → 0.1.139
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/index.cjs +418 -99
- package/dist/index.d.cts +134 -10
- package/dist/index.d.mts +134 -10
- package/dist/index.mjs +413 -100
- package/package.json +8 -8
package/dist/index.mjs
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { isLogicalKey } from "@uniqu/core";
|
|
1
2
|
import { ClientError } from "@atscript/db-client";
|
|
2
3
|
import { getDefaultClientFactory, str } from "@atscript/ui";
|
|
3
4
|
import { buildUrl } from "@uniqu/url/builder";
|
|
@@ -134,6 +135,40 @@ function columnFilterType(columnType) {
|
|
|
134
135
|
default: return "text";
|
|
135
136
|
}
|
|
136
137
|
}
|
|
138
|
+
const EXISTENCE_CONDITIONS = ["null", "notNull"];
|
|
139
|
+
const NO_CONDITIONS = [];
|
|
140
|
+
/**
|
|
141
|
+
* Filter conditions a column offers — the one answer every filter UI (column
|
|
142
|
+
* menu, filter dialog, filter bar, config dialog) reads.
|
|
143
|
+
*
|
|
144
|
+
* - Value-filterable (`filterable: true`) → {@link conditionsForType} for its
|
|
145
|
+
* display type.
|
|
146
|
+
* - Existence-only (`filterable: false`, `filterOps` includes `$exists` — a
|
|
147
|
+
* JSON-stored column) → `null` / `notNull`: whether a value is present,
|
|
148
|
+
* never what it is.
|
|
149
|
+
* - Otherwise → `[]`: the column takes no filter.
|
|
150
|
+
*
|
|
151
|
+
* `null` / `notNull` are dropped for non-nullable columns, so an existence-only
|
|
152
|
+
* column that is never empty offers nothing.
|
|
153
|
+
*
|
|
154
|
+
* @since 0.1.139
|
|
155
|
+
*/
|
|
156
|
+
function columnFilterConditions(column) {
|
|
157
|
+
if (column.filterable) return conditionsForType(columnFilterType(column.type), column.nullable);
|
|
158
|
+
if (column.nullable && column.filterOps?.includes("$exists")) return EXISTENCE_CONDITIONS;
|
|
159
|
+
return NO_CONDITIONS;
|
|
160
|
+
}
|
|
161
|
+
/**
|
|
162
|
+
* Whether a column takes any filter at all — value comparisons or the
|
|
163
|
+
* existence-only `null` / `notNull` pair. Use it (not
|
|
164
|
+
* `column.filterable`, which is value comparison only) to decide whether to
|
|
165
|
+
* show a column in a filter UI.
|
|
166
|
+
*
|
|
167
|
+
* @since 0.1.139
|
|
168
|
+
*/
|
|
169
|
+
function isColumnFilterable(column) {
|
|
170
|
+
return columnFilterConditions(column).length > 0;
|
|
171
|
+
}
|
|
137
172
|
//#endregion
|
|
138
173
|
//#region src/filters/escape-regex.ts
|
|
139
174
|
/** Escape special regex characters in user input for safe embedding in $regex. */
|
|
@@ -175,6 +210,18 @@ function defaultCondition(columnType) {
|
|
|
175
210
|
default: return "eq";
|
|
176
211
|
}
|
|
177
212
|
}
|
|
213
|
+
/**
|
|
214
|
+
* The condition a column's filter input starts with: its type's
|
|
215
|
+
* {@link defaultCondition} when the column offers it, otherwise the first
|
|
216
|
+
* condition it does offer (`null` on an existence-only column).
|
|
217
|
+
*
|
|
218
|
+
* @since 0.1.139
|
|
219
|
+
*/
|
|
220
|
+
function columnDefaultCondition(column) {
|
|
221
|
+
const type = defaultCondition(columnFilterType(column.type));
|
|
222
|
+
const offered = columnFilterConditions(column);
|
|
223
|
+
return offered.length === 0 || offered.includes(type) ? type : offered[0];
|
|
224
|
+
}
|
|
178
225
|
/** Prefix operators in match order (longest first). */
|
|
179
226
|
const PREFIX_OPS = [
|
|
180
227
|
["!=", "ne"],
|
|
@@ -207,12 +254,26 @@ const PREFIX_OPS = [
|
|
|
207
254
|
* number/date/boolean → eq
|
|
208
255
|
*
|
|
209
256
|
* Returns undefined for empty/invalid input or if the parsed operator
|
|
210
|
-
* is not available for the column type.
|
|
257
|
+
* is not available for the column type. To honour what a specific column
|
|
258
|
+
* offers (an existence-only column takes only `<empty>` / `!<empty>`), use
|
|
259
|
+
* {@link parseColumnFilterInput}.
|
|
211
260
|
*/
|
|
212
261
|
function parseFilterInput(text, columnType, nullable = true) {
|
|
262
|
+
return parseInput(text, columnType, conditionsForType(columnType, nullable));
|
|
263
|
+
}
|
|
264
|
+
/**
|
|
265
|
+
* {@link parseFilterInput} for a column: the type comes from the column and
|
|
266
|
+
* the accepted operators are {@link columnFilterConditions} — the ones the
|
|
267
|
+
* column's filter UI offers.
|
|
268
|
+
*
|
|
269
|
+
* @since 0.1.139
|
|
270
|
+
*/
|
|
271
|
+
function parseColumnFilterInput(text, column) {
|
|
272
|
+
return parseInput(text, columnFilterType(column.type), columnFilterConditions(column));
|
|
273
|
+
}
|
|
274
|
+
function parseInput(text, columnType, available) {
|
|
213
275
|
const trimmed = text.trim();
|
|
214
276
|
if (trimmed === "") return void 0;
|
|
215
|
-
const available = conditionsForType(columnType, nullable);
|
|
216
277
|
const isNumber = columnType === "number";
|
|
217
278
|
const build = (type, value) => {
|
|
218
279
|
if (!available.includes(type)) return void 0;
|
|
@@ -288,6 +349,10 @@ function formatFilterCondition(condition) {
|
|
|
288
349
|
//#region src/filters/filters-to-uniquery.ts
|
|
289
350
|
/** Exclusion condition types — AND'd together per field. */
|
|
290
351
|
const EXCLUSION_TYPES = new Set(["ne", "notNull"]);
|
|
352
|
+
/** Whether conditions of `type` are exclusions (AND'd per field) rather than inclusions (OR'd). */
|
|
353
|
+
function isExclusionType(type) {
|
|
354
|
+
return EXCLUSION_TYPES.has(type);
|
|
355
|
+
}
|
|
291
356
|
/**
|
|
292
357
|
* Convert a single condition to a Uniquery filter expression.
|
|
293
358
|
* Returns a ComparisonNode with the field as key.
|
|
@@ -336,7 +401,7 @@ function filtersToUniqueryFilter(fieldFilters) {
|
|
|
336
401
|
for (const condition of conditions) {
|
|
337
402
|
if (!isFilled(condition)) continue;
|
|
338
403
|
const expr = conditionToExpr(field, condition);
|
|
339
|
-
if (
|
|
404
|
+
if (isExclusionType(condition.type)) (exclusions ??= []).push(expr);
|
|
340
405
|
else (inclusions ??= []).push(expr);
|
|
341
406
|
}
|
|
342
407
|
if (inclusions) pushGroup(topGroups, inclusions, "$or");
|
|
@@ -347,25 +412,33 @@ function filtersToUniqueryFilter(fieldFilters) {
|
|
|
347
412
|
return { $and: topGroups };
|
|
348
413
|
}
|
|
349
414
|
//#endregion
|
|
415
|
+
//#region src/utils/dev.ts
|
|
416
|
+
/**
|
|
417
|
+
* `true` outside a production build — the gate for authoring-time warnings, so
|
|
418
|
+
* their strings and checks cost a shipped app nothing.
|
|
419
|
+
*
|
|
420
|
+
* Read from `process.env.NODE_ENV` rather than `import.meta.env`: bundlers
|
|
421
|
+
* replace this expression with a literal in both the ESM and the CJS output,
|
|
422
|
+
* while `import.meta` only exists in the former. Where neither a bundler nor a
|
|
423
|
+
* `process` shim is present the read throws — warnings stay on, which is the
|
|
424
|
+
* safe side of the trade.
|
|
425
|
+
*/
|
|
426
|
+
function detectDev() {
|
|
427
|
+
try {
|
|
428
|
+
return process.env.NODE_ENV !== "production";
|
|
429
|
+
} catch {
|
|
430
|
+
return true;
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
const DEV = detectDev();
|
|
434
|
+
//#endregion
|
|
350
435
|
//#region src/filters/uniquery-to-filters.ts
|
|
351
|
-
const SUPPORTED_TYPES = new Set([
|
|
352
|
-
"eq",
|
|
353
|
-
"ne",
|
|
354
|
-
"gt",
|
|
355
|
-
"gte",
|
|
356
|
-
"lt",
|
|
357
|
-
"lte",
|
|
358
|
-
"contains",
|
|
359
|
-
"starts",
|
|
360
|
-
"ends",
|
|
361
|
-
"bw",
|
|
362
|
-
"null",
|
|
363
|
-
"notNull",
|
|
364
|
-
"regex"
|
|
365
|
-
]);
|
|
366
436
|
function isPrimitive(v) {
|
|
367
437
|
return typeof v === "string" || typeof v === "number" || typeof v === "boolean";
|
|
368
438
|
}
|
|
439
|
+
function isPlainObject(v) {
|
|
440
|
+
return typeof v === "object" && v !== null && !Array.isArray(v) && !(v instanceof RegExp) && !(v instanceof Date);
|
|
441
|
+
}
|
|
369
442
|
const REGEX_LITERAL = /^\/(.*)\/([a-z]*)$/s;
|
|
370
443
|
const STARTS_ANCHOR = /^\^(.+)$/s;
|
|
371
444
|
const ENDS_ANCHOR = /^(.+)\$$/s;
|
|
@@ -376,6 +449,7 @@ const ENDS_ANCHOR = /^(.+)\$$/s;
|
|
|
376
449
|
* `regex` condition.
|
|
377
450
|
*/
|
|
378
451
|
function regexToCondition(raw) {
|
|
452
|
+
if (raw instanceof RegExp) raw = raw.toString();
|
|
379
453
|
if (typeof raw !== "string") return null;
|
|
380
454
|
const m = REGEX_LITERAL.exec(raw);
|
|
381
455
|
if (!m) return {
|
|
@@ -404,108 +478,324 @@ function regexToCondition(raw) {
|
|
|
404
478
|
value: [raw]
|
|
405
479
|
};
|
|
406
480
|
}
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
481
|
+
const nullCond = () => ({
|
|
482
|
+
type: "null",
|
|
483
|
+
value: []
|
|
484
|
+
});
|
|
485
|
+
const notNullCond = () => ({
|
|
486
|
+
type: "notNull",
|
|
487
|
+
value: []
|
|
488
|
+
});
|
|
489
|
+
/** Positive condition for one `$eq` / `$in` value — `null` means "is empty". */
|
|
490
|
+
function memberCondition(v) {
|
|
491
|
+
if (v === null) return nullCond();
|
|
492
|
+
return isPrimitive(v) ? {
|
|
493
|
+
type: "eq",
|
|
411
494
|
value: [v]
|
|
412
|
-
}
|
|
495
|
+
} : null;
|
|
496
|
+
}
|
|
497
|
+
/** Negative condition for one `$ne` / `$nin` value — `null` means "is not empty". */
|
|
498
|
+
function nonMemberCondition(v) {
|
|
499
|
+
if (v === null) return notNullCond();
|
|
500
|
+
return isPrimitive(v) ? {
|
|
501
|
+
type: "ne",
|
|
502
|
+
value: [v]
|
|
503
|
+
} : null;
|
|
504
|
+
}
|
|
505
|
+
const RANGE_OPS = {
|
|
506
|
+
$gt: "gt",
|
|
507
|
+
$gte: "gte",
|
|
508
|
+
$lt: "lt",
|
|
509
|
+
$lte: "lte"
|
|
510
|
+
};
|
|
511
|
+
/** Field paths an expression references, deduped, in order of appearance. */
|
|
512
|
+
function fieldsOf(expr, out = []) {
|
|
513
|
+
if (Array.isArray(expr)) {
|
|
514
|
+
for (const child of expr) fieldsOf(child, out);
|
|
515
|
+
return out;
|
|
516
|
+
}
|
|
517
|
+
if (!isPlainObject(expr)) return out;
|
|
518
|
+
for (const key in expr) if (key.startsWith("$")) fieldsOf(expr[key], out);
|
|
519
|
+
else if (!out.includes(key)) out.push(key);
|
|
520
|
+
return out;
|
|
413
521
|
}
|
|
522
|
+
function unsupported(reason, expr) {
|
|
523
|
+
const fields = fieldsOf(expr);
|
|
524
|
+
return {
|
|
525
|
+
reason: fields.length > 1 ? "cross-field" : reason,
|
|
526
|
+
expr,
|
|
527
|
+
fields
|
|
528
|
+
};
|
|
529
|
+
}
|
|
530
|
+
const isNegative = (term) => isExclusionType(term.conds[0].type);
|
|
414
531
|
/**
|
|
415
|
-
*
|
|
416
|
-
*
|
|
417
|
-
*
|
|
418
|
-
* `$gte` + `$lte` on the same field collapse into a single `bw` condition,
|
|
419
|
-
* matching the encoder's behaviour.
|
|
532
|
+
* Add decoded conditions as terms: negatives one term each (they AND), a
|
|
533
|
+
* positive list as one group (it ORs). Conditions share their polarity.
|
|
420
534
|
*/
|
|
421
|
-
function
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
value: [ops.$gte, ops.$lte]
|
|
535
|
+
function addTerms(out, field, conds, expr) {
|
|
536
|
+
if (conds.length === 0) return;
|
|
537
|
+
if (isExclusionType(conds[0].type)) for (const cond of conds) out.terms.push({
|
|
538
|
+
field,
|
|
539
|
+
conds: [cond],
|
|
540
|
+
expr
|
|
428
541
|
});
|
|
429
|
-
else {
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
542
|
+
else out.terms.push({
|
|
543
|
+
field,
|
|
544
|
+
conds,
|
|
545
|
+
expr
|
|
546
|
+
});
|
|
547
|
+
}
|
|
548
|
+
/**
|
|
549
|
+
* `a>=x` AND `a<=y` is the model's `bw`, whether both halves sit in one
|
|
550
|
+
* operator object (the encoder's shape) or in two AND-ed pieces. Folds `cond`
|
|
551
|
+
* into an earlier lone opposite half on `field`; `false` when there is none.
|
|
552
|
+
*/
|
|
553
|
+
function pairRange(out, field, cond) {
|
|
554
|
+
const opposite = cond.type === "gte" ? "lte" : cond.type === "lte" ? "gte" : null;
|
|
555
|
+
if (!opposite) return false;
|
|
556
|
+
const i = out.terms.findIndex((t) => t.field === field && t.conds.length === 1 && t.conds[0].type === opposite);
|
|
557
|
+
if (i < 0) return false;
|
|
558
|
+
const other = out.terms[i].conds[0];
|
|
559
|
+
const [lo, hi] = cond.type === "gte" ? [cond.value[0], other.value[0]] : [other.value[0], cond.value[0]];
|
|
560
|
+
out.terms[i] = {
|
|
561
|
+
field,
|
|
562
|
+
conds: [{
|
|
563
|
+
type: "bw",
|
|
564
|
+
value: [lo, hi]
|
|
565
|
+
}],
|
|
566
|
+
expr: { [field]: {
|
|
567
|
+
$gte: lo,
|
|
568
|
+
$lte: hi
|
|
569
|
+
} }
|
|
570
|
+
};
|
|
571
|
+
return true;
|
|
572
|
+
}
|
|
573
|
+
/**
|
|
574
|
+
* Decode one field entry (`field: value`) into terms. An operator object is an
|
|
575
|
+
* implicit AND of its operators — each becomes its own term.
|
|
576
|
+
*/
|
|
577
|
+
function collectField(field, value, out) {
|
|
578
|
+
const whole = { [field]: value };
|
|
579
|
+
if (value === null || value === void 0) return addTerms(out, field, [nullCond()], whole);
|
|
580
|
+
if (isPrimitive(value)) return addTerms(out, field, [{
|
|
581
|
+
type: "eq",
|
|
582
|
+
value: [value]
|
|
583
|
+
}], whole);
|
|
584
|
+
if (value instanceof RegExp) return addTerms(out, field, [regexToCondition(value)], whole);
|
|
585
|
+
if (!isPlainObject(value)) {
|
|
586
|
+
out.issues.push(unsupported("operator", whole));
|
|
587
|
+
return;
|
|
446
588
|
}
|
|
447
|
-
|
|
448
|
-
const
|
|
449
|
-
if (
|
|
589
|
+
for (const op in value) {
|
|
590
|
+
const v = value[op];
|
|
591
|
+
if (v === void 0) continue;
|
|
592
|
+
const expr = { [field]: { [op]: v } };
|
|
593
|
+
const conds = decodeOperator(op, v);
|
|
594
|
+
if (!conds) out.issues.push(unsupported("operator", expr));
|
|
595
|
+
else if (conds.length !== 1 || !pairRange(out, field, conds[0])) addTerms(out, field, conds, expr);
|
|
450
596
|
}
|
|
451
|
-
return out;
|
|
452
597
|
}
|
|
598
|
+
const one = (cond) => cond ? [cond] : null;
|
|
453
599
|
/**
|
|
454
|
-
*
|
|
455
|
-
*
|
|
600
|
+
* One operator of a field's operator object, as conditions of one polarity
|
|
601
|
+
* (see {@link addTerms}). `null` means no condition type expresses the
|
|
602
|
+
* operator / operand.
|
|
456
603
|
*/
|
|
457
|
-
function
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
}
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
604
|
+
function decodeOperator(op, v) {
|
|
605
|
+
if (op in RANGE_OPS) return one(isPrimitive(v) ? {
|
|
606
|
+
type: RANGE_OPS[op],
|
|
607
|
+
value: [v]
|
|
608
|
+
} : null);
|
|
609
|
+
switch (op) {
|
|
610
|
+
case "$eq": return one(memberCondition(v));
|
|
611
|
+
case "$ne": return one(nonMemberCondition(v));
|
|
612
|
+
case "$regex": return one(regexToCondition(v));
|
|
613
|
+
case "$exists":
|
|
614
|
+
if (v === false) return one(nullCond());
|
|
615
|
+
return v === true ? one(notNullCond()) : null;
|
|
616
|
+
case "$in": {
|
|
617
|
+
const conds = Array.isArray(v) ? v.map(memberCondition) : [];
|
|
618
|
+
return conds.length > 0 && conds.every(Boolean) ? conds : null;
|
|
619
|
+
}
|
|
620
|
+
case "$nin": {
|
|
621
|
+
if (!Array.isArray(v)) return null;
|
|
622
|
+
const conds = v.map(nonMemberCondition);
|
|
623
|
+
return conds.every(Boolean) ? conds : null;
|
|
624
|
+
}
|
|
625
|
+
default: return null;
|
|
626
|
+
}
|
|
627
|
+
}
|
|
628
|
+
/**
|
|
629
|
+
* Decode an `$or` branch / `$not` operand on its own. Usable only when it
|
|
630
|
+
* converts fully and stays on one field; `null` when it constrains nothing.
|
|
631
|
+
* A branch that spans fields comes back with a placeholder reason —
|
|
632
|
+
* {@link unsupported} names it `"cross-field"` from the fields it sees.
|
|
633
|
+
*/
|
|
634
|
+
function classifyBranch(expr) {
|
|
635
|
+
const sub = {
|
|
636
|
+
terms: [],
|
|
637
|
+
issues: []
|
|
638
|
+
};
|
|
639
|
+
collect(expr, sub);
|
|
640
|
+
if (sub.issues.length > 0) return { reason: sub.issues[0].reason };
|
|
641
|
+
if (sub.terms.length === 0) return null;
|
|
642
|
+
const field = sub.terms[0].field;
|
|
643
|
+
if (sub.terms.some((t) => t.field !== field)) return { reason: "operator" };
|
|
644
|
+
return {
|
|
645
|
+
field,
|
|
646
|
+
terms: sub.terms
|
|
647
|
+
};
|
|
648
|
+
}
|
|
649
|
+
/**
|
|
650
|
+
* `$or` fits the model only as a same-field OR of positive branches. Each
|
|
651
|
+
* branch must decode to exactly one positive group; the groups merge.
|
|
652
|
+
*/
|
|
653
|
+
function collectOr(branches, out) {
|
|
654
|
+
const expr = { $or: branches };
|
|
655
|
+
if (!Array.isArray(branches) || branches.length === 0) {
|
|
656
|
+
out.issues.push(unsupported("operator", expr));
|
|
657
|
+
return;
|
|
658
|
+
}
|
|
659
|
+
if (branches.length === 1) return collect(branches[0], out);
|
|
660
|
+
const groups = [];
|
|
661
|
+
let reason;
|
|
662
|
+
for (const branch of branches) {
|
|
663
|
+
const b = classifyBranch(branch);
|
|
664
|
+
if (b === null) return;
|
|
665
|
+
if ("reason" in b) reason ??= b.reason;
|
|
666
|
+
else if (b.terms.length > 1) reason ??= "conjunction";
|
|
667
|
+
else if (isNegative(b.terms[0])) reason ??= "negation";
|
|
668
|
+
else groups.push(b.terms[0]);
|
|
669
|
+
}
|
|
670
|
+
const field = groups[0]?.field;
|
|
671
|
+
if (reason !== void 0 || groups.some((t) => t.field !== field)) {
|
|
672
|
+
out.issues.push(unsupported(reason ?? "operator", expr));
|
|
483
673
|
return;
|
|
484
674
|
}
|
|
485
|
-
|
|
486
|
-
|
|
675
|
+
out.terms.push({
|
|
676
|
+
field,
|
|
677
|
+
conds: groups.flatMap((t) => t.conds),
|
|
678
|
+
expr
|
|
679
|
+
});
|
|
680
|
+
}
|
|
681
|
+
const INVERSE = {
|
|
682
|
+
eq: "ne",
|
|
683
|
+
ne: "eq",
|
|
684
|
+
null: "notNull",
|
|
685
|
+
notNull: "null"
|
|
686
|
+
};
|
|
687
|
+
function invert(cond) {
|
|
688
|
+
const type = INVERSE[cond.type];
|
|
689
|
+
return type ? {
|
|
690
|
+
type,
|
|
691
|
+
value: [...cond.value]
|
|
692
|
+
} : null;
|
|
693
|
+
}
|
|
694
|
+
/**
|
|
695
|
+
* `$not` fits when it inverts equality / emptiness on one field — De Morgan
|
|
696
|
+
* turns NOT(a=1 OR a=2) into a≠1 AND a≠2 (negatives), and NOT(a≠1 AND a≠2)
|
|
697
|
+
* back into a=1 OR a=2 (one positive group). A positive group AND'd with
|
|
698
|
+
* anything else would invert into an OR of ANDs, which the model cannot hold.
|
|
699
|
+
*/
|
|
700
|
+
function collectNot(child, out) {
|
|
701
|
+
const expr = { $not: child };
|
|
702
|
+
const b = classifyBranch(child);
|
|
703
|
+
if (b && !("reason" in b) && (b.terms.length === 1 || b.terms.every(isNegative))) {
|
|
704
|
+
const inverted = b.terms.flatMap((t) => t.conds).map(invert);
|
|
705
|
+
if (inverted.every(Boolean)) return addTerms(out, b.field, inverted, expr);
|
|
706
|
+
}
|
|
707
|
+
out.issues.push(unsupported("negation", expr));
|
|
708
|
+
}
|
|
709
|
+
function collectAnd(children, out) {
|
|
710
|
+
if (Array.isArray(children)) for (const child of children) collect(child, out);
|
|
711
|
+
else out.issues.push(unsupported("operator", { $and: children }));
|
|
712
|
+
}
|
|
713
|
+
const LOGICAL = {
|
|
714
|
+
$and: collectAnd,
|
|
715
|
+
$or: collectOr,
|
|
716
|
+
$not: collectNot
|
|
717
|
+
};
|
|
718
|
+
/**
|
|
719
|
+
* Split an expression into AND-ed terms. Every member of a node is an implicit
|
|
720
|
+
* AND, in key order — comparison fields may sit next to `$and` / `$or` /
|
|
721
|
+
* `$not` (Mongo semantics), and none of them is skipped.
|
|
722
|
+
*/
|
|
723
|
+
function collect(expr, out) {
|
|
724
|
+
if (!isPlainObject(expr)) {
|
|
725
|
+
out.issues.push(unsupported("operator", expr));
|
|
487
726
|
return;
|
|
488
727
|
}
|
|
489
|
-
|
|
490
|
-
|
|
728
|
+
for (const key in expr) {
|
|
729
|
+
const value = expr[key];
|
|
730
|
+
if (!key.startsWith("$")) collectField(key, value, out);
|
|
731
|
+
else if (value === void 0) continue;
|
|
732
|
+
else if (isLogicalKey(key)) LOGICAL[key](value, out);
|
|
733
|
+
else out.issues.push(unsupported("operator", { [key]: value }));
|
|
734
|
+
}
|
|
735
|
+
}
|
|
736
|
+
function sameConds(a, b) {
|
|
737
|
+
return a.length === b.length && a.every((c, i) => c.type === b[i].type && c.value.length === b[i].value.length && c.value.every((v, j) => v === b[i].value[j]));
|
|
738
|
+
}
|
|
739
|
+
function warnUnsupported(issue) {
|
|
740
|
+
if (!DEV) return;
|
|
741
|
+
console.warn(`[ui-table] Filter left out (${issue.reason}): ${JSON.stringify(issue.expr)}. Field filters cannot express it, so the result is broader than the source filter.`);
|
|
491
742
|
}
|
|
492
743
|
/**
|
|
493
744
|
* Convert a Uniquery `FilterExpr` back into the UI's `FieldFilters` shape.
|
|
494
745
|
*
|
|
495
|
-
* Inverse of `filtersToUniqueryFilter
|
|
496
|
-
*
|
|
497
|
-
*
|
|
498
|
-
*
|
|
746
|
+
* Inverse of `filtersToUniqueryFilter`, and exact for everything that encoder
|
|
747
|
+
* produces. For any other input, each AND-ed piece is either converted exactly
|
|
748
|
+
* or left out whole and reported — never approximated:
|
|
749
|
+
*
|
|
750
|
+
* - `$in` becomes equality conditions on the field, `$nin` inequality ones.
|
|
751
|
+
* - A same-field `$or` becomes that field's OR'd conditions.
|
|
752
|
+
* - A `$not` that inverts equality / emptiness becomes the inverse conditions.
|
|
753
|
+
* - Fields next to `$and` / `$or` / `$not` in one object are all kept.
|
|
754
|
+
* - Anything else (see {@link UnsupportedFilterReason}) is left out. Leaving
|
|
755
|
+
* an AND-ed piece out only ever widens the match, so the result selects a
|
|
756
|
+
* superset of `expr`. Each left-out piece goes to `onUnsupportedFilter`, or
|
|
757
|
+
* to a dev-mode `console.warn` when no handler is given.
|
|
758
|
+
*
|
|
759
|
+
* Conditions on fields outside `knownFields` (when provided) are ignored
|
|
760
|
+
* silently: they are not this table's (a host page flag, a stale column). A
|
|
761
|
+
* piece that mixes known and unknown fields is reported.
|
|
499
762
|
*
|
|
500
763
|
* Returns `{}` for an empty/missing expression. Never throws.
|
|
501
764
|
*/
|
|
502
|
-
function uniqueryFilterToFieldFilters(expr, knownFields) {
|
|
765
|
+
function uniqueryFilterToFieldFilters(expr, knownFields, onUnsupportedFilter = warnUnsupported) {
|
|
503
766
|
const acc = {};
|
|
504
767
|
if (!expr) return acc;
|
|
505
768
|
const known = knownFields == null ? null : knownFields instanceof Set ? knownFields : new Set(knownFields);
|
|
769
|
+
const isKnown = (field) => known === null || known.has(field);
|
|
770
|
+
const out = {
|
|
771
|
+
terms: [],
|
|
772
|
+
issues: []
|
|
773
|
+
};
|
|
506
774
|
try {
|
|
507
|
-
|
|
508
|
-
} catch {
|
|
775
|
+
collect(expr, out);
|
|
776
|
+
} catch {
|
|
777
|
+
out.terms = [];
|
|
778
|
+
out.issues = [{
|
|
779
|
+
reason: "operator",
|
|
780
|
+
expr,
|
|
781
|
+
fields: []
|
|
782
|
+
}];
|
|
783
|
+
}
|
|
784
|
+
const positives = /* @__PURE__ */ new Map();
|
|
785
|
+
for (const term of out.terms) {
|
|
786
|
+
if (!isKnown(term.field)) continue;
|
|
787
|
+
const list = acc[term.field] ??= [];
|
|
788
|
+
const kept = positives.get(term.field);
|
|
789
|
+
if (isNegative(term) || !kept) {
|
|
790
|
+
if (!isNegative(term)) positives.set(term.field, term.conds);
|
|
791
|
+
list.push(...term.conds);
|
|
792
|
+
} else if (!sameConds(kept, term.conds)) out.issues.push({
|
|
793
|
+
reason: "conjunction",
|
|
794
|
+
expr: term.expr,
|
|
795
|
+
fields: [term.field]
|
|
796
|
+
});
|
|
797
|
+
}
|
|
798
|
+
for (const issue of out.issues) if (issue.fields.length === 0 || issue.fields.some(isKnown)) onUnsupportedFilter(issue);
|
|
509
799
|
return acc;
|
|
510
800
|
}
|
|
511
801
|
//#endregion
|
|
@@ -1213,6 +1503,20 @@ function buildTableQuery(opts) {
|
|
|
1213
1503
|
}
|
|
1214
1504
|
//#endregion
|
|
1215
1505
|
//#region src/query/url-query.ts
|
|
1506
|
+
/**
|
|
1507
|
+
* The URL control that marks a query string as a complete snapshot of the
|
|
1508
|
+
* table's filters and sorters. Restoring a URL that carries it clears every
|
|
1509
|
+
* filter and sorter the URL owns before applying its own, so nothing the URL
|
|
1510
|
+
* omits survives (empty `$sort` included). A URL without it is an overlay:
|
|
1511
|
+
* its filters and sorters are laid over the table's starting ones.
|
|
1512
|
+
*
|
|
1513
|
+
* The encoder writes it bare (`…&$snapshot`) on every URL; the decoder goes
|
|
1514
|
+
* by presence alone, whatever the value. `UrlQuerySync.snapshot: false` turns
|
|
1515
|
+
* both off.
|
|
1516
|
+
*
|
|
1517
|
+
* @since 0.1.139
|
|
1518
|
+
*/
|
|
1519
|
+
const URL_SNAPSHOT_KEY = "$snapshot";
|
|
1216
1520
|
function resolveAspectGate(value) {
|
|
1217
1521
|
if (value === void 0 || value === true) return "all";
|
|
1218
1522
|
if (value === false) return "none";
|
|
@@ -1251,7 +1555,9 @@ function pickFilterPaths(filters, gate) {
|
|
|
1251
1555
|
* `$actions`, `forceFilters`, `forceSorters` — those are not user state) and
|
|
1252
1556
|
* appends `$skip` / `$limit` for pagination.
|
|
1253
1557
|
*
|
|
1254
|
-
*
|
|
1558
|
+
* Stamps {@link URL_SNAPSHOT_KEY} unless `defaults.sync.snapshot` is `false`
|
|
1559
|
+
* (or neither filters nor sorters sync), so the default view serializes to
|
|
1560
|
+
* `"$snapshot"`; with the marker off it serializes to `""` (no leading `?`).
|
|
1255
1561
|
*/
|
|
1256
1562
|
function stateToUrlQueryString(state, defaults) {
|
|
1257
1563
|
const filtersGate = resolveAspectGate(defaults.sync?.filters);
|
|
@@ -1271,6 +1577,7 @@ function stateToUrlQueryString(state, defaults) {
|
|
|
1271
1577
|
const page = state.page ?? 1;
|
|
1272
1578
|
if (page > 1) query.controls.$skip = (page - 1) * itemsPerPage;
|
|
1273
1579
|
}
|
|
1580
|
+
if (defaults.sync?.snapshot !== false && (filtersGate !== "none" || sortersGate !== "none")) query.controls[URL_SNAPSHOT_KEY] = "";
|
|
1274
1581
|
return buildUrl(query);
|
|
1275
1582
|
}
|
|
1276
1583
|
/**
|
|
@@ -1281,7 +1588,8 @@ const CONSUMED_CONTROLS = new Set([
|
|
|
1281
1588
|
"$sort",
|
|
1282
1589
|
"$search",
|
|
1283
1590
|
"$relevance",
|
|
1284
|
-
"$skip"
|
|
1591
|
+
"$skip",
|
|
1592
|
+
URL_SNAPSHOT_KEY
|
|
1285
1593
|
]);
|
|
1286
1594
|
/** Characters that can only appear in a uniqu filter key, never in a page flag. */
|
|
1287
1595
|
const FILTER_OPERATOR_CHAR = /[<>!~]/;
|
|
@@ -1309,7 +1617,9 @@ function urlQueryConsumesKey(key) {
|
|
|
1309
1617
|
* Robust by design — schema drift and copy-paste errors must not break the
|
|
1310
1618
|
* recipient's view:
|
|
1311
1619
|
* - unknown fields (not in `knownFields`) → silently dropped
|
|
1312
|
-
* -
|
|
1620
|
+
* - filter pieces field filters cannot express (cross-field OR, unknown
|
|
1621
|
+
* operator, …) → left out of `filters` and listed in `unsupported`, never
|
|
1622
|
+
* approximated (the parser does not warn — the caller decides)
|
|
1313
1623
|
* - unknown controls (e.g. `$weird=42`) → silently ignored
|
|
1314
1624
|
* - malformed query → `{ filters: {}, sorters: [], searchTerm: "" }`
|
|
1315
1625
|
*
|
|
@@ -1344,7 +1654,8 @@ function urlQueryStringToState(urlString, opts = {}) {
|
|
|
1344
1654
|
filterKnown = /* @__PURE__ */ new Set();
|
|
1345
1655
|
for (const path of filtersGate) if (knownSet.has(path)) filterKnown.add(path);
|
|
1346
1656
|
} else filterKnown = filtersGate;
|
|
1347
|
-
const
|
|
1657
|
+
const unsupported = [];
|
|
1658
|
+
const filters = filtersGate === "none" ? {} : uniqueryFilterToFieldFilters(parsed.filter, filterKnown, (issue) => unsupported.push(issue));
|
|
1348
1659
|
const sorters = [];
|
|
1349
1660
|
if (sortersGate !== "none") {
|
|
1350
1661
|
const $sort = parsed.controls?.$sort;
|
|
@@ -1368,6 +1679,8 @@ function urlQueryStringToState(urlString, opts = {}) {
|
|
|
1368
1679
|
sorters,
|
|
1369
1680
|
searchTerm: !searchOff && typeof $search === "string" ? $search : ""
|
|
1370
1681
|
};
|
|
1682
|
+
if (unsupported.length > 0) out.unsupported = unsupported;
|
|
1683
|
+
if (opts.sync?.snapshot !== false && parsed.controls && "$snapshot" in parsed.controls) out.snapshot = true;
|
|
1371
1684
|
if (!searchOff) {
|
|
1372
1685
|
const $relevance = parsed.controls?.$relevance;
|
|
1373
1686
|
if ($relevance === "1" || $relevance === 1) out.ignoreSorters = true;
|
|
@@ -1918,4 +2231,4 @@ function sortRowsLocally(rows, sorters, getValue = (row, field) => row[field]) {
|
|
|
1918
2231
|
});
|
|
1919
2232
|
}
|
|
1920
2233
|
//#endregion
|
|
1921
|
-
export { APP_CONF_PREFIX, AppPrefsClient, DEFAULT_EXPORT_PAGE_SIZE, DEFAULT_ROW_HEIGHT_PX, DRAFT_PERSISTED_ASPECTS, ExportAbortError, MAX_DEFAULT_COLUMN_WIDTH_PX, NULL_OPS, PRESET_ASPECTS, PresetsClient, PresetsHttpError, RESERVED_ID_PREFIXES, STANDARD_PRESET_ID, SYSTEM_PRESET_PREFIX, USER_CONF_PREFIX, appConfId, arraysEqual, blockStartFor, buildTableQuery, cellAsString, clampTopIndex, collectExportRows, columnFilterType, computeDefaultColumnWidth, conditionLabel, conditionsForType, csvCell, dateShortcuts, debounce, defaultCondition, derivePresetAspects, deserializeDraft, draftMatchesPreset, escapeRegex, filledFilterCount, filterTokenLabel, filtersToUniqueryFilter, formatFilterCondition, fromWireSnapshot, gateOwns, hasSecondValue, isAuthError, isDirtyAgainst, isEmptyDraft, isFilled, isSimpleEq, isSystemPresetId, isUnavailableError, mergeDisplayColumns, mergeFilters, mergeSorters, normaliseSystemPresetId, pageAlignedBlocksFor, parseFilterInput, planFetch, reconcileColumnWidthDefaults, reorderColumnNames, resolveAspectGate, resolveExportValue, resolveSystemPresets, rowsToPks, sameColumnSet, serializeDraft, setsEqual, sortRowsLocally, sortersEqual, stableStringify, stateToUrlQueryString, toCsv, toWireSnapshot, togglePk, trimSelection, unescapeRegex, uniqueryFilterToFieldFilters, urlQueryConsumesKey, urlQueryStringToState, userConfId, walkBackwardAbsorb, walkForwardAbsorb, withStableOrder };
|
|
2234
|
+
export { APP_CONF_PREFIX, AppPrefsClient, DEFAULT_EXPORT_PAGE_SIZE, DEFAULT_ROW_HEIGHT_PX, DEV, DRAFT_PERSISTED_ASPECTS, ExportAbortError, MAX_DEFAULT_COLUMN_WIDTH_PX, NULL_OPS, PRESET_ASPECTS, PresetsClient, PresetsHttpError, RESERVED_ID_PREFIXES, STANDARD_PRESET_ID, SYSTEM_PRESET_PREFIX, URL_SNAPSHOT_KEY, USER_CONF_PREFIX, appConfId, arraysEqual, blockStartFor, buildTableQuery, cellAsString, clampTopIndex, collectExportRows, columnDefaultCondition, columnFilterConditions, columnFilterType, computeDefaultColumnWidth, conditionLabel, conditionsForType, csvCell, dateShortcuts, debounce, defaultCondition, derivePresetAspects, deserializeDraft, draftMatchesPreset, escapeRegex, filledFilterCount, filterTokenLabel, filtersToUniqueryFilter, formatFilterCondition, fromWireSnapshot, gateOwns, hasSecondValue, isAuthError, isColumnFilterable, isDirtyAgainst, isEmptyDraft, isFilled, isSimpleEq, isSystemPresetId, isUnavailableError, mergeDisplayColumns, mergeFilters, mergeSorters, normaliseSystemPresetId, pageAlignedBlocksFor, parseColumnFilterInput, parseFilterInput, planFetch, reconcileColumnWidthDefaults, reorderColumnNames, resolveAspectGate, resolveExportValue, resolveSystemPresets, rowsToPks, sameColumnSet, serializeDraft, setsEqual, sortRowsLocally, sortersEqual, stableStringify, stateToUrlQueryString, toCsv, toWireSnapshot, togglePk, trimSelection, unescapeRegex, uniqueryFilterToFieldFilters, urlQueryConsumesKey, urlQueryStringToState, userConfId, walkBackwardAbsorb, walkForwardAbsorb, withStableOrder };
|