@atscript/ui-table 0.1.138 → 0.1.140
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 +605 -114
- package/dist/index.d.cts +270 -15
- package/dist/index.d.mts +270 -15
- package/dist/index.mjs +594 -115
- package/package.json +8 -8
package/dist/index.cjs
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
|
|
2
|
+
let _uniqu_core = require("@uniqu/core");
|
|
3
|
+
let _uniqu_url_builder = require("@uniqu/url/builder");
|
|
2
4
|
let _atscript_db_client = require("@atscript/db-client");
|
|
3
5
|
let _atscript_ui = require("@atscript/ui");
|
|
4
|
-
let _uniqu_url_builder = require("@uniqu/url/builder");
|
|
5
6
|
let _uniqu_url = require("@uniqu/url");
|
|
6
7
|
//#region src/filters/filter-conditions.ts
|
|
7
8
|
/** Conditions that operate purely on nullability — value is ignored. */
|
|
@@ -135,6 +136,40 @@ function columnFilterType(columnType) {
|
|
|
135
136
|
default: return "text";
|
|
136
137
|
}
|
|
137
138
|
}
|
|
139
|
+
const EXISTENCE_CONDITIONS = ["null", "notNull"];
|
|
140
|
+
const NO_CONDITIONS = [];
|
|
141
|
+
/**
|
|
142
|
+
* Filter conditions a column offers — the one answer every filter UI (column
|
|
143
|
+
* menu, filter dialog, filter bar, config dialog) reads.
|
|
144
|
+
*
|
|
145
|
+
* - Value-filterable (`filterable: true`) → {@link conditionsForType} for its
|
|
146
|
+
* display type.
|
|
147
|
+
* - Existence-only (`filterable: false`, `filterOps` includes `$exists` — a
|
|
148
|
+
* JSON-stored column) → `null` / `notNull`: whether a value is present,
|
|
149
|
+
* never what it is.
|
|
150
|
+
* - Otherwise → `[]`: the column takes no filter.
|
|
151
|
+
*
|
|
152
|
+
* `null` / `notNull` are dropped for non-nullable columns, so an existence-only
|
|
153
|
+
* column that is never empty offers nothing.
|
|
154
|
+
*
|
|
155
|
+
* @since 0.1.139
|
|
156
|
+
*/
|
|
157
|
+
function columnFilterConditions(column) {
|
|
158
|
+
if (column.filterable) return conditionsForType(columnFilterType(column.type), column.nullable);
|
|
159
|
+
if (column.nullable && column.filterOps?.includes("$exists")) return EXISTENCE_CONDITIONS;
|
|
160
|
+
return NO_CONDITIONS;
|
|
161
|
+
}
|
|
162
|
+
/**
|
|
163
|
+
* Whether a column takes any filter at all — value comparisons or the
|
|
164
|
+
* existence-only `null` / `notNull` pair. Use it (not
|
|
165
|
+
* `column.filterable`, which is value comparison only) to decide whether to
|
|
166
|
+
* show a column in a filter UI.
|
|
167
|
+
*
|
|
168
|
+
* @since 0.1.139
|
|
169
|
+
*/
|
|
170
|
+
function isColumnFilterable(column) {
|
|
171
|
+
return columnFilterConditions(column).length > 0;
|
|
172
|
+
}
|
|
138
173
|
//#endregion
|
|
139
174
|
//#region src/filters/escape-regex.ts
|
|
140
175
|
/** Escape special regex characters in user input for safe embedding in $regex. */
|
|
@@ -176,6 +211,18 @@ function defaultCondition(columnType) {
|
|
|
176
211
|
default: return "eq";
|
|
177
212
|
}
|
|
178
213
|
}
|
|
214
|
+
/**
|
|
215
|
+
* The condition a column's filter input starts with: its type's
|
|
216
|
+
* {@link defaultCondition} when the column offers it, otherwise the first
|
|
217
|
+
* condition it does offer (`null` on an existence-only column).
|
|
218
|
+
*
|
|
219
|
+
* @since 0.1.139
|
|
220
|
+
*/
|
|
221
|
+
function columnDefaultCondition(column) {
|
|
222
|
+
const type = defaultCondition(columnFilterType(column.type));
|
|
223
|
+
const offered = columnFilterConditions(column);
|
|
224
|
+
return offered.length === 0 || offered.includes(type) ? type : offered[0];
|
|
225
|
+
}
|
|
179
226
|
/** Prefix operators in match order (longest first). */
|
|
180
227
|
const PREFIX_OPS = [
|
|
181
228
|
["!=", "ne"],
|
|
@@ -208,12 +255,26 @@ const PREFIX_OPS = [
|
|
|
208
255
|
* number/date/boolean → eq
|
|
209
256
|
*
|
|
210
257
|
* Returns undefined for empty/invalid input or if the parsed operator
|
|
211
|
-
* is not available for the column type.
|
|
258
|
+
* is not available for the column type. To honour what a specific column
|
|
259
|
+
* offers (an existence-only column takes only `<empty>` / `!<empty>`), use
|
|
260
|
+
* {@link parseColumnFilterInput}.
|
|
212
261
|
*/
|
|
213
262
|
function parseFilterInput(text, columnType, nullable = true) {
|
|
263
|
+
return parseInput(text, columnType, conditionsForType(columnType, nullable));
|
|
264
|
+
}
|
|
265
|
+
/**
|
|
266
|
+
* {@link parseFilterInput} for a column: the type comes from the column and
|
|
267
|
+
* the accepted operators are {@link columnFilterConditions} — the ones the
|
|
268
|
+
* column's filter UI offers.
|
|
269
|
+
*
|
|
270
|
+
* @since 0.1.139
|
|
271
|
+
*/
|
|
272
|
+
function parseColumnFilterInput(text, column) {
|
|
273
|
+
return parseInput(text, columnFilterType(column.type), columnFilterConditions(column));
|
|
274
|
+
}
|
|
275
|
+
function parseInput(text, columnType, available) {
|
|
214
276
|
const trimmed = text.trim();
|
|
215
277
|
if (trimmed === "") return void 0;
|
|
216
|
-
const available = conditionsForType(columnType, nullable);
|
|
217
278
|
const isNumber = columnType === "number";
|
|
218
279
|
const build = (type, value) => {
|
|
219
280
|
if (!available.includes(type)) return void 0;
|
|
@@ -289,6 +350,10 @@ function formatFilterCondition(condition) {
|
|
|
289
350
|
//#region src/filters/filters-to-uniquery.ts
|
|
290
351
|
/** Exclusion condition types — AND'd together per field. */
|
|
291
352
|
const EXCLUSION_TYPES = new Set(["ne", "notNull"]);
|
|
353
|
+
/** Whether conditions of `type` are exclusions (AND'd per field) rather than inclusions (OR'd). */
|
|
354
|
+
function isExclusionType(type) {
|
|
355
|
+
return EXCLUSION_TYPES.has(type);
|
|
356
|
+
}
|
|
292
357
|
/**
|
|
293
358
|
* Convert a single condition to a Uniquery filter expression.
|
|
294
359
|
* Returns a ComparisonNode with the field as key.
|
|
@@ -337,7 +402,7 @@ function filtersToUniqueryFilter(fieldFilters) {
|
|
|
337
402
|
for (const condition of conditions) {
|
|
338
403
|
if (!isFilled(condition)) continue;
|
|
339
404
|
const expr = conditionToExpr(field, condition);
|
|
340
|
-
if (
|
|
405
|
+
if (isExclusionType(condition.type)) (exclusions ??= []).push(expr);
|
|
341
406
|
else (inclusions ??= []).push(expr);
|
|
342
407
|
}
|
|
343
408
|
if (inclusions) pushGroup(topGroups, inclusions, "$or");
|
|
@@ -348,25 +413,33 @@ function filtersToUniqueryFilter(fieldFilters) {
|
|
|
348
413
|
return { $and: topGroups };
|
|
349
414
|
}
|
|
350
415
|
//#endregion
|
|
416
|
+
//#region src/utils/dev.ts
|
|
417
|
+
/**
|
|
418
|
+
* `true` outside a production build — the gate for authoring-time warnings, so
|
|
419
|
+
* their strings and checks cost a shipped app nothing.
|
|
420
|
+
*
|
|
421
|
+
* Read from `process.env.NODE_ENV` rather than `import.meta.env`: bundlers
|
|
422
|
+
* replace this expression with a literal in both the ESM and the CJS output,
|
|
423
|
+
* while `import.meta` only exists in the former. Where neither a bundler nor a
|
|
424
|
+
* `process` shim is present the read throws — warnings stay on, which is the
|
|
425
|
+
* safe side of the trade.
|
|
426
|
+
*/
|
|
427
|
+
function detectDev() {
|
|
428
|
+
try {
|
|
429
|
+
return process.env.NODE_ENV !== "production";
|
|
430
|
+
} catch {
|
|
431
|
+
return true;
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
const DEV = detectDev();
|
|
435
|
+
//#endregion
|
|
351
436
|
//#region src/filters/uniquery-to-filters.ts
|
|
352
|
-
const SUPPORTED_TYPES = new Set([
|
|
353
|
-
"eq",
|
|
354
|
-
"ne",
|
|
355
|
-
"gt",
|
|
356
|
-
"gte",
|
|
357
|
-
"lt",
|
|
358
|
-
"lte",
|
|
359
|
-
"contains",
|
|
360
|
-
"starts",
|
|
361
|
-
"ends",
|
|
362
|
-
"bw",
|
|
363
|
-
"null",
|
|
364
|
-
"notNull",
|
|
365
|
-
"regex"
|
|
366
|
-
]);
|
|
367
437
|
function isPrimitive(v) {
|
|
368
438
|
return typeof v === "string" || typeof v === "number" || typeof v === "boolean";
|
|
369
439
|
}
|
|
440
|
+
function isPlainObject(v) {
|
|
441
|
+
return typeof v === "object" && v !== null && !Array.isArray(v) && !(v instanceof RegExp) && !(v instanceof Date);
|
|
442
|
+
}
|
|
370
443
|
const REGEX_LITERAL = /^\/(.*)\/([a-z]*)$/s;
|
|
371
444
|
const STARTS_ANCHOR = /^\^(.+)$/s;
|
|
372
445
|
const ENDS_ANCHOR = /^(.+)\$$/s;
|
|
@@ -377,6 +450,7 @@ const ENDS_ANCHOR = /^(.+)\$$/s;
|
|
|
377
450
|
* `regex` condition.
|
|
378
451
|
*/
|
|
379
452
|
function regexToCondition(raw) {
|
|
453
|
+
if (raw instanceof RegExp) raw = raw.toString();
|
|
380
454
|
if (typeof raw !== "string") return null;
|
|
381
455
|
const m = REGEX_LITERAL.exec(raw);
|
|
382
456
|
if (!m) return {
|
|
@@ -405,109 +479,469 @@ function regexToCondition(raw) {
|
|
|
405
479
|
value: [raw]
|
|
406
480
|
};
|
|
407
481
|
}
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
482
|
+
const nullCond = () => ({
|
|
483
|
+
type: "null",
|
|
484
|
+
value: []
|
|
485
|
+
});
|
|
486
|
+
const notNullCond = () => ({
|
|
487
|
+
type: "notNull",
|
|
488
|
+
value: []
|
|
489
|
+
});
|
|
490
|
+
/** Positive condition for one `$eq` / `$in` value — `null` means "is empty". */
|
|
491
|
+
function memberCondition(v) {
|
|
492
|
+
if (v === null) return nullCond();
|
|
493
|
+
return isPrimitive(v) ? {
|
|
494
|
+
type: "eq",
|
|
412
495
|
value: [v]
|
|
413
|
-
}
|
|
496
|
+
} : null;
|
|
414
497
|
}
|
|
498
|
+
/** Negative condition for one `$ne` / `$nin` value — `null` means "is not empty". */
|
|
499
|
+
function nonMemberCondition(v) {
|
|
500
|
+
if (v === null) return notNullCond();
|
|
501
|
+
return isPrimitive(v) ? {
|
|
502
|
+
type: "ne",
|
|
503
|
+
value: [v]
|
|
504
|
+
} : null;
|
|
505
|
+
}
|
|
506
|
+
const RANGE_OPS = {
|
|
507
|
+
$gt: "gt",
|
|
508
|
+
$gte: "gte",
|
|
509
|
+
$lt: "lt",
|
|
510
|
+
$lte: "lte"
|
|
511
|
+
};
|
|
415
512
|
/**
|
|
416
|
-
*
|
|
417
|
-
*
|
|
513
|
+
* Field paths a Uniquery filter expression references, deduped, in order of
|
|
514
|
+
* appearance (logical operators are walked, operator keys skipped).
|
|
418
515
|
*
|
|
419
|
-
*
|
|
420
|
-
* matching the encoder's behaviour.
|
|
516
|
+
* @internal Exported for `@atscript/vue-table`.
|
|
421
517
|
*/
|
|
422
|
-
function
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
if (
|
|
427
|
-
|
|
428
|
-
|
|
518
|
+
function filterExprFields(expr) {
|
|
519
|
+
return fieldsOf(expr, []);
|
|
520
|
+
}
|
|
521
|
+
function fieldsOf(expr, out) {
|
|
522
|
+
if (Array.isArray(expr)) {
|
|
523
|
+
for (const child of expr) fieldsOf(child, out);
|
|
524
|
+
return out;
|
|
525
|
+
}
|
|
526
|
+
if (!isPlainObject(expr)) return out;
|
|
527
|
+
for (const key in expr) if (key.startsWith("$")) fieldsOf(expr[key], out);
|
|
528
|
+
else if (!out.includes(key)) out.push(key);
|
|
529
|
+
return out;
|
|
530
|
+
}
|
|
531
|
+
function unsupported(reason, expr) {
|
|
532
|
+
const fields = fieldsOf(expr, []);
|
|
533
|
+
return {
|
|
534
|
+
reason: fields.length > 1 ? "cross-field" : reason,
|
|
535
|
+
expr,
|
|
536
|
+
fields
|
|
537
|
+
};
|
|
538
|
+
}
|
|
539
|
+
const isNegative = (term) => isExclusionType(term.conds[0].type);
|
|
540
|
+
/**
|
|
541
|
+
* Add decoded conditions as terms: negatives one term each (they AND), a
|
|
542
|
+
* positive list as one group (it ORs). Conditions share their polarity.
|
|
543
|
+
*/
|
|
544
|
+
function addTerms(out, field, conds, expr) {
|
|
545
|
+
if (conds.length === 0) return;
|
|
546
|
+
if (isExclusionType(conds[0].type)) for (const cond of conds) out.terms.push({
|
|
547
|
+
field,
|
|
548
|
+
conds: [cond],
|
|
549
|
+
expr
|
|
429
550
|
});
|
|
430
|
-
else {
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
551
|
+
else out.terms.push({
|
|
552
|
+
field,
|
|
553
|
+
conds,
|
|
554
|
+
expr
|
|
555
|
+
});
|
|
556
|
+
}
|
|
557
|
+
/**
|
|
558
|
+
* `a>=x` AND `a<=y` is the model's `bw`, whether both halves sit in one
|
|
559
|
+
* operator object (the encoder's shape) or in two AND-ed pieces. Folds `cond`
|
|
560
|
+
* into an earlier lone opposite half on `field`; `false` when there is none.
|
|
561
|
+
*/
|
|
562
|
+
function pairRange(out, field, cond) {
|
|
563
|
+
const opposite = cond.type === "gte" ? "lte" : cond.type === "lte" ? "gte" : null;
|
|
564
|
+
if (!opposite) return false;
|
|
565
|
+
const i = out.terms.findIndex((t) => t.field === field && t.conds.length === 1 && t.conds[0].type === opposite);
|
|
566
|
+
if (i < 0) return false;
|
|
567
|
+
const other = out.terms[i].conds[0];
|
|
568
|
+
const [lo, hi] = cond.type === "gte" ? [cond.value[0], other.value[0]] : [other.value[0], cond.value[0]];
|
|
569
|
+
out.terms[i] = {
|
|
570
|
+
field,
|
|
571
|
+
conds: [{
|
|
572
|
+
type: "bw",
|
|
573
|
+
value: [lo, hi]
|
|
574
|
+
}],
|
|
575
|
+
expr: { [field]: {
|
|
576
|
+
$gte: lo,
|
|
577
|
+
$lte: hi
|
|
578
|
+
} }
|
|
579
|
+
};
|
|
580
|
+
return true;
|
|
581
|
+
}
|
|
582
|
+
/**
|
|
583
|
+
* Decode one field entry (`field: value`) into terms. An operator object is an
|
|
584
|
+
* implicit AND of its operators — each becomes its own term.
|
|
585
|
+
*/
|
|
586
|
+
function collectField(field, value, out) {
|
|
587
|
+
const whole = { [field]: value };
|
|
588
|
+
if (value === null || value === void 0) return addTerms(out, field, [nullCond()], whole);
|
|
589
|
+
if (isPrimitive(value)) return addTerms(out, field, [{
|
|
590
|
+
type: "eq",
|
|
591
|
+
value: [value]
|
|
592
|
+
}], whole);
|
|
593
|
+
if (value instanceof RegExp) return addTerms(out, field, [regexToCondition(value)], whole);
|
|
594
|
+
if (!isPlainObject(value)) {
|
|
595
|
+
out.issues.push(unsupported("operator", whole));
|
|
596
|
+
return;
|
|
447
597
|
}
|
|
448
|
-
|
|
449
|
-
const
|
|
450
|
-
if (
|
|
598
|
+
for (const op in value) {
|
|
599
|
+
const v = value[op];
|
|
600
|
+
if (v === void 0) continue;
|
|
601
|
+
const expr = { [field]: { [op]: v } };
|
|
602
|
+
const conds = decodeOperator(op, v);
|
|
603
|
+
if (!conds) out.issues.push(unsupported("operator", expr));
|
|
604
|
+
else if (conds.length !== 1 || !pairRange(out, field, conds[0])) addTerms(out, field, conds, expr);
|
|
451
605
|
}
|
|
452
|
-
return out;
|
|
453
606
|
}
|
|
607
|
+
const one = (cond) => cond ? [cond] : null;
|
|
454
608
|
/**
|
|
455
|
-
*
|
|
456
|
-
*
|
|
609
|
+
* One operator of a field's operator object, as conditions of one polarity
|
|
610
|
+
* (see {@link addTerms}). `null` means no condition type expresses the
|
|
611
|
+
* operator / operand.
|
|
457
612
|
*/
|
|
458
|
-
function
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
}
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
613
|
+
function decodeOperator(op, v) {
|
|
614
|
+
if (op in RANGE_OPS) return one(isPrimitive(v) ? {
|
|
615
|
+
type: RANGE_OPS[op],
|
|
616
|
+
value: [v]
|
|
617
|
+
} : null);
|
|
618
|
+
switch (op) {
|
|
619
|
+
case "$eq": return one(memberCondition(v));
|
|
620
|
+
case "$ne": return one(nonMemberCondition(v));
|
|
621
|
+
case "$regex": return one(regexToCondition(v));
|
|
622
|
+
case "$exists":
|
|
623
|
+
if (v === false) return one(nullCond());
|
|
624
|
+
return v === true ? one(notNullCond()) : null;
|
|
625
|
+
case "$in": {
|
|
626
|
+
const conds = Array.isArray(v) ? v.map(memberCondition) : [];
|
|
627
|
+
return conds.length > 0 && conds.every(Boolean) ? conds : null;
|
|
628
|
+
}
|
|
629
|
+
case "$nin": {
|
|
630
|
+
if (!Array.isArray(v)) return null;
|
|
631
|
+
const conds = v.map(nonMemberCondition);
|
|
632
|
+
return conds.every(Boolean) ? conds : null;
|
|
633
|
+
}
|
|
634
|
+
default: return null;
|
|
635
|
+
}
|
|
636
|
+
}
|
|
637
|
+
/**
|
|
638
|
+
* Decode an `$or` branch / `$not` operand on its own. Usable only when it
|
|
639
|
+
* converts fully and stays on one field; `null` when it constrains nothing.
|
|
640
|
+
* A branch that spans fields comes back with a placeholder reason —
|
|
641
|
+
* {@link unsupported} names it `"cross-field"` from the fields it sees.
|
|
642
|
+
*/
|
|
643
|
+
function classifyBranch(expr) {
|
|
644
|
+
const sub = {
|
|
645
|
+
terms: [],
|
|
646
|
+
issues: []
|
|
647
|
+
};
|
|
648
|
+
collect(expr, sub);
|
|
649
|
+
if (sub.issues.length > 0) return { reason: sub.issues[0].reason };
|
|
650
|
+
if (sub.terms.length === 0) return null;
|
|
651
|
+
const field = sub.terms[0].field;
|
|
652
|
+
if (sub.terms.some((t) => t.field !== field)) return { reason: "operator" };
|
|
653
|
+
return {
|
|
654
|
+
field,
|
|
655
|
+
terms: sub.terms
|
|
656
|
+
};
|
|
657
|
+
}
|
|
658
|
+
/**
|
|
659
|
+
* `$or` fits the model only as a same-field OR of positive branches. Each
|
|
660
|
+
* branch must decode to exactly one positive group; the groups merge.
|
|
661
|
+
*/
|
|
662
|
+
function collectOr(branches, out) {
|
|
663
|
+
const expr = { $or: branches };
|
|
664
|
+
if (!Array.isArray(branches) || branches.length === 0) {
|
|
665
|
+
out.issues.push(unsupported("operator", expr));
|
|
484
666
|
return;
|
|
485
667
|
}
|
|
486
|
-
if (
|
|
487
|
-
|
|
668
|
+
if (branches.length === 1) return collect(branches[0], out);
|
|
669
|
+
const groups = [];
|
|
670
|
+
let reason;
|
|
671
|
+
for (const branch of branches) {
|
|
672
|
+
const b = classifyBranch(branch);
|
|
673
|
+
if (b === null) return;
|
|
674
|
+
if ("reason" in b) reason ??= b.reason;
|
|
675
|
+
else if (b.terms.length > 1) reason ??= "conjunction";
|
|
676
|
+
else if (isNegative(b.terms[0])) reason ??= "negation";
|
|
677
|
+
else groups.push(b.terms[0]);
|
|
678
|
+
}
|
|
679
|
+
const field = groups[0]?.field;
|
|
680
|
+
if (reason !== void 0 || groups.some((t) => t.field !== field)) {
|
|
681
|
+
out.issues.push(unsupported(reason ?? "operator", expr));
|
|
682
|
+
return;
|
|
683
|
+
}
|
|
684
|
+
out.terms.push({
|
|
685
|
+
field,
|
|
686
|
+
conds: groups.flatMap((t) => t.conds),
|
|
687
|
+
expr
|
|
688
|
+
});
|
|
689
|
+
}
|
|
690
|
+
const INVERSE = {
|
|
691
|
+
eq: "ne",
|
|
692
|
+
ne: "eq",
|
|
693
|
+
null: "notNull",
|
|
694
|
+
notNull: "null"
|
|
695
|
+
};
|
|
696
|
+
function invert(cond) {
|
|
697
|
+
const type = INVERSE[cond.type];
|
|
698
|
+
return type ? {
|
|
699
|
+
type,
|
|
700
|
+
value: [...cond.value]
|
|
701
|
+
} : null;
|
|
702
|
+
}
|
|
703
|
+
/**
|
|
704
|
+
* `$not` fits when it inverts equality / emptiness on one field — De Morgan
|
|
705
|
+
* turns NOT(a=1 OR a=2) into a≠1 AND a≠2 (negatives), and NOT(a≠1 AND a≠2)
|
|
706
|
+
* back into a=1 OR a=2 (one positive group). A positive group AND'd with
|
|
707
|
+
* anything else would invert into an OR of ANDs, which the model cannot hold.
|
|
708
|
+
*/
|
|
709
|
+
function collectNot(child, out) {
|
|
710
|
+
if (isPlainObject(child) && Object.keys(child).length === 1 && "$not" in child) return collect(child.$not, out);
|
|
711
|
+
const expr = { $not: child };
|
|
712
|
+
const b = classifyBranch(child);
|
|
713
|
+
if (b && !("reason" in b) && (b.terms.length === 1 || b.terms.every(isNegative))) {
|
|
714
|
+
const inverted = b.terms.flatMap((t) => t.conds).map(invert);
|
|
715
|
+
if (inverted.every(Boolean)) return addTerms(out, b.field, inverted, expr);
|
|
716
|
+
}
|
|
717
|
+
out.issues.push(unsupported("negation", expr));
|
|
718
|
+
}
|
|
719
|
+
function collectAnd(children, out) {
|
|
720
|
+
if (Array.isArray(children)) for (const child of children) collect(child, out);
|
|
721
|
+
else out.issues.push(unsupported("operator", { $and: children }));
|
|
722
|
+
}
|
|
723
|
+
const LOGICAL = {
|
|
724
|
+
$and: collectAnd,
|
|
725
|
+
$or: collectOr,
|
|
726
|
+
$not: collectNot
|
|
727
|
+
};
|
|
728
|
+
/**
|
|
729
|
+
* Split an expression into AND-ed terms. Every member of a node is an implicit
|
|
730
|
+
* AND, in key order — comparison fields may sit next to `$and` / `$or` /
|
|
731
|
+
* `$not` (Mongo semantics), and none of them is skipped.
|
|
732
|
+
*/
|
|
733
|
+
function collect(expr, out) {
|
|
734
|
+
if (!isPlainObject(expr)) {
|
|
735
|
+
out.issues.push(unsupported("operator", expr));
|
|
488
736
|
return;
|
|
489
737
|
}
|
|
490
|
-
|
|
491
|
-
|
|
738
|
+
for (const key in expr) {
|
|
739
|
+
const value = expr[key];
|
|
740
|
+
if (!key.startsWith("$")) collectField(key, value, out);
|
|
741
|
+
else if (value === void 0) continue;
|
|
742
|
+
else if ((0, _uniqu_core.isLogicalKey)(key)) LOGICAL[key](value, out);
|
|
743
|
+
else out.issues.push(unsupported("operator", { [key]: value }));
|
|
744
|
+
}
|
|
745
|
+
}
|
|
746
|
+
function sameConds(a, b) {
|
|
747
|
+
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]));
|
|
748
|
+
}
|
|
749
|
+
function warnUnsupported(issue) {
|
|
750
|
+
if (!DEV) return;
|
|
751
|
+
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.`);
|
|
752
|
+
}
|
|
753
|
+
/**
|
|
754
|
+
* Split a Uniquery `FilterExpr` into what the table's field-filter model
|
|
755
|
+
* holds exactly (`filters`), what it cannot hold but carries as residual
|
|
756
|
+
* conditions (`residual`, with `carry`), and what it leaves out
|
|
757
|
+
* (`unsupported`). Nothing is approximated: `filters AND residual` selects
|
|
758
|
+
* `expr` minus the `unsupported` pieces and pieces on fields outside
|
|
759
|
+
* `knownFields`.
|
|
760
|
+
*
|
|
761
|
+
* Never throws, never warns — the caller decides how to report.
|
|
762
|
+
*
|
|
763
|
+
* @since 0.1.140
|
|
764
|
+
*/
|
|
765
|
+
function decomposeUniqueryFilter(expr, opts = {}) {
|
|
766
|
+
const filters = {};
|
|
767
|
+
const result = {
|
|
768
|
+
filters,
|
|
769
|
+
residual: [],
|
|
770
|
+
unsupported: []
|
|
771
|
+
};
|
|
772
|
+
if (!expr) return result;
|
|
773
|
+
const knownFields = opts.knownFields;
|
|
774
|
+
const known = knownFields == null ? null : knownFields instanceof Set ? knownFields : new Set(knownFields);
|
|
775
|
+
const isKnown = (field) => known === null || known.has(field);
|
|
776
|
+
const out = {
|
|
777
|
+
terms: [],
|
|
778
|
+
issues: []
|
|
779
|
+
};
|
|
780
|
+
try {
|
|
781
|
+
collect(expr, out);
|
|
782
|
+
} catch {
|
|
783
|
+
out.terms = [];
|
|
784
|
+
out.issues = [{
|
|
785
|
+
reason: "operator",
|
|
786
|
+
expr,
|
|
787
|
+
fields: []
|
|
788
|
+
}];
|
|
789
|
+
}
|
|
790
|
+
const positives = /* @__PURE__ */ new Map();
|
|
791
|
+
for (const term of out.terms) {
|
|
792
|
+
if (!isKnown(term.field)) continue;
|
|
793
|
+
const list = filters[term.field] ??= [];
|
|
794
|
+
if (isNegative(term)) {
|
|
795
|
+
list.push(...term.conds);
|
|
796
|
+
continue;
|
|
797
|
+
}
|
|
798
|
+
const groups = positives.get(term.field);
|
|
799
|
+
if (!groups) {
|
|
800
|
+
positives.set(term.field, [term]);
|
|
801
|
+
list.push(...term.conds);
|
|
802
|
+
} else if (!groups.some((g) => sameConds(g.conds, term.conds))) groups.push(term);
|
|
803
|
+
}
|
|
804
|
+
const carried = [];
|
|
805
|
+
for (const [field, groups] of positives) {
|
|
806
|
+
if (groups.length < 2) continue;
|
|
807
|
+
const leftOut = opts.carry ? groups : groups.slice(1);
|
|
808
|
+
if (opts.carry) {
|
|
809
|
+
const kept = new Set(groups[0].conds);
|
|
810
|
+
const rest = filters[field].filter((c) => !kept.has(c));
|
|
811
|
+
if (rest.length > 0) filters[field] = rest;
|
|
812
|
+
else delete filters[field];
|
|
813
|
+
}
|
|
814
|
+
for (const term of leftOut) out.issues.push({
|
|
815
|
+
reason: "conjunction",
|
|
816
|
+
expr: term.expr,
|
|
817
|
+
fields: [field]
|
|
818
|
+
});
|
|
819
|
+
}
|
|
820
|
+
for (const issue of out.issues) {
|
|
821
|
+
const fields = issue.fields;
|
|
822
|
+
if (fields.length > 0 && !fields.some(isKnown)) continue;
|
|
823
|
+
if (opts.carry && fields.length > 0 && fields.every(isKnown)) carried.push(issue.expr);
|
|
824
|
+
else result.unsupported.push(issue);
|
|
825
|
+
}
|
|
826
|
+
result.residual = normalizeResidualFilters(carried);
|
|
827
|
+
return result;
|
|
828
|
+
}
|
|
829
|
+
/**
|
|
830
|
+
* Canonical identity of a filter expression — its `@uniqu/url` spelling.
|
|
831
|
+
* Two expressions with the same key select the same rows.
|
|
832
|
+
*
|
|
833
|
+
* @since 0.1.140
|
|
834
|
+
*/
|
|
835
|
+
function filterExprKey(expr) {
|
|
836
|
+
try {
|
|
837
|
+
return (0, _uniqu_url_builder.buildUrl)({ filter: expr });
|
|
838
|
+
} catch {
|
|
839
|
+
return JSON.stringify(expr) ?? "";
|
|
840
|
+
}
|
|
841
|
+
}
|
|
842
|
+
/**
|
|
843
|
+
* A residual-condition list with empty expressions and duplicates (by
|
|
844
|
+
* {@link filterExprKey}) dropped, sorted by key. Sorted, not first-appearance:
|
|
845
|
+
* the URL parser moves \`$not\`-wrapped clauses (how \`mergeFilters\` spells
|
|
846
|
+
* a repeated same-field clause) ahead of plain ones, so appearance order would
|
|
847
|
+
* flip on every round trip.
|
|
848
|
+
*
|
|
849
|
+
* @internal Exported for `@atscript/vue-table`.
|
|
850
|
+
*/
|
|
851
|
+
function normalizeResidualFilters(exprs) {
|
|
852
|
+
const byKey = /* @__PURE__ */ new Map();
|
|
853
|
+
for (const expr of exprs) {
|
|
854
|
+
if (!isPlainObject(expr)) continue;
|
|
855
|
+
const key = filterExprKey(expr);
|
|
856
|
+
if (key && !byKey.has(key)) byKey.set(key, expr);
|
|
857
|
+
}
|
|
858
|
+
return [...byKey.keys()].toSorted().map((key) => byKey.get(key));
|
|
492
859
|
}
|
|
493
860
|
/**
|
|
494
861
|
* Convert a Uniquery `FilterExpr` back into the UI's `FieldFilters` shape.
|
|
495
862
|
*
|
|
496
|
-
* Inverse of `filtersToUniqueryFilter
|
|
497
|
-
*
|
|
498
|
-
*
|
|
499
|
-
*
|
|
863
|
+
* Inverse of `filtersToUniqueryFilter`, and exact for everything that encoder
|
|
864
|
+
* produces. For any other input, each AND-ed piece is either converted exactly
|
|
865
|
+
* or left out whole and reported — never approximated:
|
|
866
|
+
*
|
|
867
|
+
* - `$in` becomes equality conditions on the field, `$nin` inequality ones.
|
|
868
|
+
* - A same-field `$or` becomes that field's OR'd conditions.
|
|
869
|
+
* - A `$not` that inverts equality / emptiness becomes the inverse conditions.
|
|
870
|
+
* - Fields next to `$and` / `$or` / `$not` in one object are all kept.
|
|
871
|
+
* - Anything else (see {@link UnsupportedFilterReason}) is left out. Leaving
|
|
872
|
+
* an AND-ed piece out only ever widens the match, so the result selects a
|
|
873
|
+
* superset of `expr`. Each left-out piece goes to `onUnsupportedFilter`, or
|
|
874
|
+
* to a dev-mode `console.warn` when no handler is given. To keep those
|
|
875
|
+
* pieces instead, use {@link decomposeUniqueryFilter} with `carry`.
|
|
876
|
+
*
|
|
877
|
+
* Conditions on fields outside `knownFields` (when provided) are ignored
|
|
878
|
+
* silently: they are not this table's (a host page flag, a stale column). A
|
|
879
|
+
* piece that mixes known and unknown fields is reported.
|
|
500
880
|
*
|
|
501
881
|
* Returns `{}` for an empty/missing expression. Never throws.
|
|
502
882
|
*/
|
|
503
|
-
function uniqueryFilterToFieldFilters(expr, knownFields) {
|
|
504
|
-
const
|
|
505
|
-
|
|
506
|
-
|
|
883
|
+
function uniqueryFilterToFieldFilters(expr, knownFields, onUnsupportedFilter = warnUnsupported) {
|
|
884
|
+
const { filters, unsupported } = decomposeUniqueryFilter(expr, { knownFields });
|
|
885
|
+
for (const issue of unsupported) onUnsupportedFilter(issue);
|
|
886
|
+
return filters;
|
|
887
|
+
}
|
|
888
|
+
//#endregion
|
|
889
|
+
//#region src/filters/format-filter-expr.ts
|
|
890
|
+
function show(v) {
|
|
891
|
+
if (v instanceof Date) return v.toISOString();
|
|
892
|
+
if (typeof v === "object" && v !== null) return JSON.stringify(v) ?? "";
|
|
893
|
+
return String(v);
|
|
894
|
+
}
|
|
895
|
+
/** One comparison, worded like the filter chips (the decoder's operator table). */
|
|
896
|
+
function leaf(label, op, value) {
|
|
897
|
+
if (op === "$eq" && value instanceof RegExp) op = "$regex";
|
|
898
|
+
if ((op === "$in" || op === "$nin") && Array.isArray(value)) return `${label} ${op === "$in" ? "is one of" : "is none of"} ${value.map(show).join(", ")}`;
|
|
899
|
+
const conds = decodeOperator(op, value);
|
|
900
|
+
if (conds?.length === 1) return filterTokenLabel(label, conds, label);
|
|
901
|
+
return (0, _uniqu_url_builder.buildUrl)({ filter: { [label]: { [op]: value } } }) || `${label}${op}`;
|
|
902
|
+
}
|
|
903
|
+
function join(children, kind) {
|
|
904
|
+
const parts = children.filter((c) => c.s);
|
|
905
|
+
if (parts.length === 0) return {
|
|
906
|
+
s: "",
|
|
907
|
+
kind: "leaf"
|
|
908
|
+
};
|
|
909
|
+
if (parts.length === 1) return parts[0];
|
|
910
|
+
const wrap = kind === "and" ? "or" : "and";
|
|
911
|
+
return {
|
|
912
|
+
s: parts.map((c) => c.kind === wrap ? `(${c.s})` : c.s).join(` ${kind} `),
|
|
913
|
+
kind
|
|
914
|
+
};
|
|
915
|
+
}
|
|
916
|
+
/**
|
|
917
|
+
* Human-readable rendering of a Uniquery filter expression, worded like the
|
|
918
|
+
* filter-field chips: `(Status equals shipped and Total greater than 500) or
|
|
919
|
+
* (Status equals pending and Total less or equal 50)`. `and` binds tighter
|
|
920
|
+
* than `or`; groups are parenthesized only where needed. Operators without a
|
|
921
|
+
* wording fall back to their `@uniqu/url` spelling.
|
|
922
|
+
*
|
|
923
|
+
* @param labelOf — display label for a field path (e.g. the column label);
|
|
924
|
+
* the path itself when omitted or when it returns `undefined`.
|
|
925
|
+
* @since 0.1.140
|
|
926
|
+
*/
|
|
927
|
+
function formatFilterExpr(expr, labelOf) {
|
|
928
|
+
const visitor = {
|
|
929
|
+
comparison: (field, op, value) => ({
|
|
930
|
+
s: leaf(labelOf?.(field) ?? field, op, value),
|
|
931
|
+
kind: "leaf"
|
|
932
|
+
}),
|
|
933
|
+
and: (children) => join(children, "and"),
|
|
934
|
+
or: (children) => join(children, "or"),
|
|
935
|
+
not: (child) => ({
|
|
936
|
+
s: child.s ? `not (${child.s})` : "",
|
|
937
|
+
kind: "leaf"
|
|
938
|
+
})
|
|
939
|
+
};
|
|
507
940
|
try {
|
|
508
|
-
|
|
509
|
-
} catch {
|
|
510
|
-
|
|
941
|
+
return (0, _uniqu_core.walkFilter)(expr, visitor)?.s ?? "";
|
|
942
|
+
} catch {
|
|
943
|
+
return JSON.stringify(expr) ?? "";
|
|
944
|
+
}
|
|
511
945
|
}
|
|
512
946
|
//#endregion
|
|
513
947
|
//#region src/filters/date-shortcuts.ts
|
|
@@ -1104,8 +1538,9 @@ function mergeSorters(forceSorters, userSorters) {
|
|
|
1104
1538
|
//#endregion
|
|
1105
1539
|
//#region src/query/merge-filters.ts
|
|
1106
1540
|
/**
|
|
1107
|
-
* AND-merge
|
|
1108
|
-
* `@uniqu/url`'s `mergeConjunction` parser collapse.
|
|
1541
|
+
* AND-merge filter expressions (`undefined` ones skipped), producing a wire
|
|
1542
|
+
* shape that survives `@uniqu/url`'s `mergeConjunction` parser collapse.
|
|
1543
|
+
* Variadic since 0.1.140.
|
|
1109
1544
|
*
|
|
1110
1545
|
* The collapse problem: when two `$and` siblings target the same field
|
|
1111
1546
|
* with the same op (e.g. `{status: 'cancelled'}` AND `{status: 'shipped'}`),
|
|
@@ -1118,10 +1553,10 @@ function mergeSorters(forceSorters, userSorters) {
|
|
|
1118
1553
|
* and `!!p ≡ p` is a semantic identity, so the server evaluator sees the
|
|
1119
1554
|
* same AND. Non-colliding merges produce the canonical `$and` shape.
|
|
1120
1555
|
*/
|
|
1121
|
-
function mergeFilters(
|
|
1122
|
-
|
|
1123
|
-
if (
|
|
1124
|
-
return makeParserSafeAnd(
|
|
1556
|
+
function mergeFilters(...exprs) {
|
|
1557
|
+
const list = exprs.filter((e) => !!e);
|
|
1558
|
+
if (list.length <= 1) return list[0];
|
|
1559
|
+
return makeParserSafeAnd(list);
|
|
1125
1560
|
}
|
|
1126
1561
|
/** Op-set for a field value: primitives are `$eq`, op-bags expose their keys. */
|
|
1127
1562
|
function getOpsForFieldValue(value) {
|
|
@@ -1189,12 +1624,12 @@ function makeParserSafeAnd(children) {
|
|
|
1189
1624
|
* Build a Uniquery object from table UI state.
|
|
1190
1625
|
*
|
|
1191
1626
|
* Pure function — no framework dependencies.
|
|
1192
|
-
* Combines user filters
|
|
1627
|
+
* Combines user filters (field filters, then residual conditions) with force
|
|
1628
|
+
* filters, merges sorters,
|
|
1193
1629
|
* projects visible columns, and applies pagination.
|
|
1194
1630
|
*/
|
|
1195
1631
|
function buildTableQuery(opts) {
|
|
1196
|
-
const
|
|
1197
|
-
const filter = mergeFilters(opts.forceFilters, userFilter);
|
|
1632
|
+
const filter = mergeFilters(opts.forceFilters, filtersToUniqueryFilter(opts.filters), ...opts.residualFilters ?? []);
|
|
1198
1633
|
const userSorters = opts.ignoreSorters ? [] : opts.sorters;
|
|
1199
1634
|
const sorters = opts.forceSorters?.length ? mergeSorters(opts.forceSorters, userSorters) : userSorters;
|
|
1200
1635
|
const $sort = {};
|
|
@@ -1214,6 +1649,20 @@ function buildTableQuery(opts) {
|
|
|
1214
1649
|
}
|
|
1215
1650
|
//#endregion
|
|
1216
1651
|
//#region src/query/url-query.ts
|
|
1652
|
+
/**
|
|
1653
|
+
* The URL control that marks a query string as a complete snapshot of the
|
|
1654
|
+
* table's filters and sorters. Restoring a URL that carries it clears every
|
|
1655
|
+
* filter and sorter the URL owns before applying its own, so nothing the URL
|
|
1656
|
+
* omits survives (empty `$sort` included). A URL without it is an overlay:
|
|
1657
|
+
* its filters and sorters are laid over the table's starting ones.
|
|
1658
|
+
*
|
|
1659
|
+
* The encoder writes it bare (`…&$snapshot`) on every URL; the decoder goes
|
|
1660
|
+
* by presence alone, whatever the value. `UrlQuerySync.snapshot: false` turns
|
|
1661
|
+
* both off.
|
|
1662
|
+
*
|
|
1663
|
+
* @since 0.1.139
|
|
1664
|
+
*/
|
|
1665
|
+
const URL_SNAPSHOT_KEY = "$snapshot";
|
|
1217
1666
|
function resolveAspectGate(value) {
|
|
1218
1667
|
if (value === void 0 || value === true) return "all";
|
|
1219
1668
|
if (value === false) return "none";
|
|
@@ -1240,6 +1689,17 @@ function gateOwns(gate, path) {
|
|
|
1240
1689
|
if (gate === "none") return false;
|
|
1241
1690
|
return gate.has(path);
|
|
1242
1691
|
}
|
|
1692
|
+
/**
|
|
1693
|
+
* Does a URL under `gate` own the residual condition `expr`? Only when it
|
|
1694
|
+
* owns every field the condition references — a condition that touches a
|
|
1695
|
+
* private field stays private as a whole.
|
|
1696
|
+
*
|
|
1697
|
+
* @internal Exported for `@atscript/vue-table`.
|
|
1698
|
+
*/
|
|
1699
|
+
function residualGateOwns(gate, expr) {
|
|
1700
|
+
const fields = filterExprFields(expr);
|
|
1701
|
+
return fields.length > 0 && fields.every((path) => gateOwns(gate, path));
|
|
1702
|
+
}
|
|
1243
1703
|
function pickFilterPaths(filters, gate) {
|
|
1244
1704
|
const out = {};
|
|
1245
1705
|
for (const path in filters) if (gateOwns(gate, path)) out[path] = filters[path];
|
|
@@ -1252,7 +1712,9 @@ function pickFilterPaths(filters, gate) {
|
|
|
1252
1712
|
* `$actions`, `forceFilters`, `forceSorters` — those are not user state) and
|
|
1253
1713
|
* appends `$skip` / `$limit` for pagination.
|
|
1254
1714
|
*
|
|
1255
|
-
*
|
|
1715
|
+
* Stamps {@link URL_SNAPSHOT_KEY} unless `defaults.sync.snapshot` is `false`
|
|
1716
|
+
* (or neither filters nor sorters sync), so the default view serializes to
|
|
1717
|
+
* `"$snapshot"`; with the marker off it serializes to `""` (no leading `?`).
|
|
1256
1718
|
*/
|
|
1257
1719
|
function stateToUrlQueryString(state, defaults) {
|
|
1258
1720
|
const filtersGate = resolveAspectGate(defaults.sync?.filters);
|
|
@@ -1264,6 +1726,7 @@ function stateToUrlQueryString(state, defaults) {
|
|
|
1264
1726
|
visibleColumnPaths: [],
|
|
1265
1727
|
sorters: sortersGate === "all" ? state.sorters : state.sorters.filter((s) => gateOwns(sortersGate, s.field)),
|
|
1266
1728
|
filters,
|
|
1729
|
+
residualFilters: defaults.sync?.residual === false || !state.residualFilters?.length ? void 0 : state.residualFilters.filter((expr) => residualGateOwns(filtersGate, expr)),
|
|
1267
1730
|
search: searchOff ? void 0 : state.searchTerm || void 0
|
|
1268
1731
|
});
|
|
1269
1732
|
if (!searchOff && state.searchTerm && state.ignoreSorters !== void 0 && state.ignoreSorters !== (defaults.defaultIgnoreSorters ?? false)) query.controls.$relevance = state.ignoreSorters ? 1 : 0;
|
|
@@ -1272,6 +1735,7 @@ function stateToUrlQueryString(state, defaults) {
|
|
|
1272
1735
|
const page = state.page ?? 1;
|
|
1273
1736
|
if (page > 1) query.controls.$skip = (page - 1) * itemsPerPage;
|
|
1274
1737
|
}
|
|
1738
|
+
if (defaults.sync?.snapshot !== false && (filtersGate !== "none" || sortersGate !== "none")) query.controls[URL_SNAPSHOT_KEY] = "";
|
|
1275
1739
|
return (0, _uniqu_url_builder.buildUrl)(query);
|
|
1276
1740
|
}
|
|
1277
1741
|
/**
|
|
@@ -1282,10 +1746,14 @@ const CONSUMED_CONTROLS = new Set([
|
|
|
1282
1746
|
"$sort",
|
|
1283
1747
|
"$search",
|
|
1284
1748
|
"$relevance",
|
|
1285
|
-
"$skip"
|
|
1749
|
+
"$skip",
|
|
1750
|
+
URL_SNAPSHOT_KEY
|
|
1286
1751
|
]);
|
|
1287
|
-
/**
|
|
1288
|
-
|
|
1752
|
+
/**
|
|
1753
|
+
* Characters that can only appear in a uniqu filter key, never in a page flag:
|
|
1754
|
+
* comparison operators, `^` (OR), `( )` (groups) and `{ }` (`$in` lists).
|
|
1755
|
+
*/
|
|
1756
|
+
const FILTER_OPERATOR_CHAR = /[<>!~()^{}]/;
|
|
1289
1757
|
/**
|
|
1290
1758
|
* Whether {@link urlQueryStringToState} would consume the query key `key` —
|
|
1291
1759
|
* i.e. whether the key belongs to the table rather than to the page hosting
|
|
@@ -1310,7 +1778,11 @@ function urlQueryConsumesKey(key) {
|
|
|
1310
1778
|
* Robust by design — schema drift and copy-paste errors must not break the
|
|
1311
1779
|
* recipient's view:
|
|
1312
1780
|
* - unknown fields (not in `knownFields`) → silently dropped
|
|
1313
|
-
* -
|
|
1781
|
+
* - filter pieces field filters cannot express (cross-field OR, unknown
|
|
1782
|
+
* operator, …) → left out of `filters` and listed in `unsupported`, never
|
|
1783
|
+
* approximated (the parser does not warn — the caller decides). Unless
|
|
1784
|
+
* `sync.residual` is `false`, those whose fields are all known come back
|
|
1785
|
+
* in `residual` instead
|
|
1314
1786
|
* - unknown controls (e.g. `$weird=42`) → silently ignored
|
|
1315
1787
|
* - malformed query → `{ filters: {}, sorters: [], searchTerm: "" }`
|
|
1316
1788
|
*
|
|
@@ -1345,7 +1817,11 @@ function urlQueryStringToState(urlString, opts = {}) {
|
|
|
1345
1817
|
filterKnown = /* @__PURE__ */ new Set();
|
|
1346
1818
|
for (const path of filtersGate) if (knownSet.has(path)) filterKnown.add(path);
|
|
1347
1819
|
} else filterKnown = filtersGate;
|
|
1348
|
-
const
|
|
1820
|
+
const decomposed = filtersGate === "none" ? null : decomposeUniqueryFilter(parsed.filter, {
|
|
1821
|
+
knownFields: filterKnown,
|
|
1822
|
+
carry: opts.sync?.residual !== false
|
|
1823
|
+
});
|
|
1824
|
+
const filters = decomposed?.filters ?? {};
|
|
1349
1825
|
const sorters = [];
|
|
1350
1826
|
if (sortersGate !== "none") {
|
|
1351
1827
|
const $sort = parsed.controls?.$sort;
|
|
@@ -1369,6 +1845,9 @@ function urlQueryStringToState(urlString, opts = {}) {
|
|
|
1369
1845
|
sorters,
|
|
1370
1846
|
searchTerm: !searchOff && typeof $search === "string" ? $search : ""
|
|
1371
1847
|
};
|
|
1848
|
+
if (decomposed?.unsupported.length) out.unsupported = decomposed.unsupported;
|
|
1849
|
+
if (decomposed?.residual.length) out.residual = decomposed.residual;
|
|
1850
|
+
if (opts.sync?.snapshot !== false && parsed.controls && "$snapshot" in parsed.controls) out.snapshot = true;
|
|
1372
1851
|
if (!searchOff) {
|
|
1373
1852
|
const $relevance = parsed.controls?.$relevance;
|
|
1374
1853
|
if ($relevance === "1" || $relevance === 1) out.ignoreSorters = true;
|
|
@@ -1923,6 +2402,7 @@ exports.APP_CONF_PREFIX = APP_CONF_PREFIX;
|
|
|
1923
2402
|
exports.AppPrefsClient = AppPrefsClient;
|
|
1924
2403
|
exports.DEFAULT_EXPORT_PAGE_SIZE = DEFAULT_EXPORT_PAGE_SIZE;
|
|
1925
2404
|
exports.DEFAULT_ROW_HEIGHT_PX = DEFAULT_ROW_HEIGHT_PX;
|
|
2405
|
+
exports.DEV = DEV;
|
|
1926
2406
|
exports.DRAFT_PERSISTED_ASPECTS = DRAFT_PERSISTED_ASPECTS;
|
|
1927
2407
|
exports.ExportAbortError = ExportAbortError;
|
|
1928
2408
|
exports.MAX_DEFAULT_COLUMN_WIDTH_PX = MAX_DEFAULT_COLUMN_WIDTH_PX;
|
|
@@ -1933,6 +2413,7 @@ exports.PresetsHttpError = PresetsHttpError;
|
|
|
1933
2413
|
exports.RESERVED_ID_PREFIXES = RESERVED_ID_PREFIXES;
|
|
1934
2414
|
exports.STANDARD_PRESET_ID = STANDARD_PRESET_ID;
|
|
1935
2415
|
exports.SYSTEM_PRESET_PREFIX = SYSTEM_PRESET_PREFIX;
|
|
2416
|
+
exports.URL_SNAPSHOT_KEY = URL_SNAPSHOT_KEY;
|
|
1936
2417
|
exports.USER_CONF_PREFIX = USER_CONF_PREFIX;
|
|
1937
2418
|
exports.appConfId = appConfId;
|
|
1938
2419
|
exports.arraysEqual = arraysEqual;
|
|
@@ -1941,6 +2422,8 @@ exports.buildTableQuery = buildTableQuery;
|
|
|
1941
2422
|
exports.cellAsString = cellAsString;
|
|
1942
2423
|
exports.clampTopIndex = clampTopIndex;
|
|
1943
2424
|
exports.collectExportRows = collectExportRows;
|
|
2425
|
+
exports.columnDefaultCondition = columnDefaultCondition;
|
|
2426
|
+
exports.columnFilterConditions = columnFilterConditions;
|
|
1944
2427
|
exports.columnFilterType = columnFilterType;
|
|
1945
2428
|
exports.computeDefaultColumnWidth = computeDefaultColumnWidth;
|
|
1946
2429
|
exports.conditionLabel = conditionLabel;
|
|
@@ -1948,19 +2431,24 @@ exports.conditionsForType = conditionsForType;
|
|
|
1948
2431
|
exports.csvCell = csvCell;
|
|
1949
2432
|
exports.dateShortcuts = dateShortcuts;
|
|
1950
2433
|
exports.debounce = debounce;
|
|
2434
|
+
exports.decomposeUniqueryFilter = decomposeUniqueryFilter;
|
|
1951
2435
|
exports.defaultCondition = defaultCondition;
|
|
1952
2436
|
exports.derivePresetAspects = derivePresetAspects;
|
|
1953
2437
|
exports.deserializeDraft = deserializeDraft;
|
|
1954
2438
|
exports.draftMatchesPreset = draftMatchesPreset;
|
|
1955
2439
|
exports.escapeRegex = escapeRegex;
|
|
1956
2440
|
exports.filledFilterCount = filledFilterCount;
|
|
2441
|
+
exports.filterExprFields = filterExprFields;
|
|
2442
|
+
exports.filterExprKey = filterExprKey;
|
|
1957
2443
|
exports.filterTokenLabel = filterTokenLabel;
|
|
1958
2444
|
exports.filtersToUniqueryFilter = filtersToUniqueryFilter;
|
|
1959
2445
|
exports.formatFilterCondition = formatFilterCondition;
|
|
2446
|
+
exports.formatFilterExpr = formatFilterExpr;
|
|
1960
2447
|
exports.fromWireSnapshot = fromWireSnapshot;
|
|
1961
2448
|
exports.gateOwns = gateOwns;
|
|
1962
2449
|
exports.hasSecondValue = hasSecondValue;
|
|
1963
2450
|
exports.isAuthError = isAuthError;
|
|
2451
|
+
exports.isColumnFilterable = isColumnFilterable;
|
|
1964
2452
|
exports.isDirtyAgainst = isDirtyAgainst;
|
|
1965
2453
|
exports.isEmptyDraft = isEmptyDraft;
|
|
1966
2454
|
exports.isFilled = isFilled;
|
|
@@ -1971,11 +2459,14 @@ exports.mergeDisplayColumns = mergeDisplayColumns;
|
|
|
1971
2459
|
exports.mergeFilters = mergeFilters;
|
|
1972
2460
|
exports.mergeSorters = mergeSorters;
|
|
1973
2461
|
exports.normaliseSystemPresetId = normaliseSystemPresetId;
|
|
2462
|
+
exports.normalizeResidualFilters = normalizeResidualFilters;
|
|
1974
2463
|
exports.pageAlignedBlocksFor = pageAlignedBlocksFor;
|
|
2464
|
+
exports.parseColumnFilterInput = parseColumnFilterInput;
|
|
1975
2465
|
exports.parseFilterInput = parseFilterInput;
|
|
1976
2466
|
exports.planFetch = planFetch;
|
|
1977
2467
|
exports.reconcileColumnWidthDefaults = reconcileColumnWidthDefaults;
|
|
1978
2468
|
exports.reorderColumnNames = reorderColumnNames;
|
|
2469
|
+
exports.residualGateOwns = residualGateOwns;
|
|
1979
2470
|
exports.resolveAspectGate = resolveAspectGate;
|
|
1980
2471
|
exports.resolveExportValue = resolveExportValue;
|
|
1981
2472
|
exports.resolveSystemPresets = resolveSystemPresets;
|