@atscript/ui-table 0.1.139 → 0.1.141
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 +414 -63
- package/dist/index.d.cts +287 -20
- package/dist/index.d.mts +287 -20
- package/dist/index.mjs +407 -65
- package/package.json +8 -8
package/dist/index.mjs
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
import { isLogicalKey } from "@uniqu/core";
|
|
1
|
+
import { isLogicalKey, walkFilter } from "@uniqu/core";
|
|
2
|
+
import { buildUrl } from "@uniqu/url/builder";
|
|
2
3
|
import { ClientError } from "@atscript/db-client";
|
|
3
4
|
import { getDefaultClientFactory, str } from "@atscript/ui";
|
|
4
|
-
import { buildUrl } from "@uniqu/url/builder";
|
|
5
5
|
import { parseUrl } from "@uniqu/url";
|
|
6
6
|
//#region src/filters/filter-conditions.ts
|
|
7
7
|
/** Conditions that operate purely on nullability — value is ignored. */
|
|
@@ -508,8 +508,16 @@ const RANGE_OPS = {
|
|
|
508
508
|
$lt: "lt",
|
|
509
509
|
$lte: "lte"
|
|
510
510
|
};
|
|
511
|
-
/**
|
|
512
|
-
|
|
511
|
+
/**
|
|
512
|
+
* Field paths a Uniquery filter expression references, deduped, in order of
|
|
513
|
+
* appearance (logical operators are walked, operator keys skipped).
|
|
514
|
+
*
|
|
515
|
+
* @internal Exported for `@atscript/vue-table`.
|
|
516
|
+
*/
|
|
517
|
+
function filterExprFields(expr) {
|
|
518
|
+
return fieldsOf(expr, []);
|
|
519
|
+
}
|
|
520
|
+
function fieldsOf(expr, out) {
|
|
513
521
|
if (Array.isArray(expr)) {
|
|
514
522
|
for (const child of expr) fieldsOf(child, out);
|
|
515
523
|
return out;
|
|
@@ -519,8 +527,9 @@ function fieldsOf(expr, out = []) {
|
|
|
519
527
|
else if (!out.includes(key)) out.push(key);
|
|
520
528
|
return out;
|
|
521
529
|
}
|
|
530
|
+
/** @internal Module-shared with the URL parser; not a package export. */
|
|
522
531
|
function unsupported(reason, expr) {
|
|
523
|
-
const fields = fieldsOf(expr);
|
|
532
|
+
const fields = fieldsOf(expr, []);
|
|
524
533
|
return {
|
|
525
534
|
reason: fields.length > 1 ? "cross-field" : reason,
|
|
526
535
|
expr,
|
|
@@ -698,6 +707,7 @@ function invert(cond) {
|
|
|
698
707
|
* anything else would invert into an OR of ANDs, which the model cannot hold.
|
|
699
708
|
*/
|
|
700
709
|
function collectNot(child, out) {
|
|
710
|
+
if (isPlainObject(child) && Object.keys(child).length === 1 && "$not" in child) return collect(child.$not, out);
|
|
701
711
|
const expr = { $not: child };
|
|
702
712
|
const b = classifyBranch(child);
|
|
703
713
|
if (b && !("reason" in b) && (b.terms.length === 1 || b.terms.every(isNegative))) {
|
|
@@ -741,30 +751,27 @@ function warnUnsupported(issue) {
|
|
|
741
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.`);
|
|
742
752
|
}
|
|
743
753
|
/**
|
|
744
|
-
*
|
|
745
|
-
*
|
|
746
|
-
*
|
|
747
|
-
*
|
|
748
|
-
*
|
|
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.
|
|
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`), what it leaves out (`unsupported`)
|
|
757
|
+
* and what names a field outside `knownFields` (`unknown`). Nothing is
|
|
758
|
+
* approximated: `filters AND residual` selects `expr` minus the
|
|
759
|
+
* `unsupported` and `unknown` pieces.
|
|
758
760
|
*
|
|
759
|
-
*
|
|
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.
|
|
761
|
+
* Never throws, never warns — the caller decides how to report.
|
|
762
762
|
*
|
|
763
|
-
*
|
|
763
|
+
* @since 0.1.140
|
|
764
764
|
*/
|
|
765
|
-
function
|
|
766
|
-
const
|
|
767
|
-
|
|
765
|
+
function decomposeUniqueryFilter(expr, opts = {}) {
|
|
766
|
+
const filters = {};
|
|
767
|
+
const result = {
|
|
768
|
+
filters,
|
|
769
|
+
residual: [],
|
|
770
|
+
unsupported: [],
|
|
771
|
+
unknown: []
|
|
772
|
+
};
|
|
773
|
+
if (!expr) return result;
|
|
774
|
+
const knownFields = opts.knownFields;
|
|
768
775
|
const known = knownFields == null ? null : knownFields instanceof Set ? knownFields : new Set(knownFields);
|
|
769
776
|
const isKnown = (field) => known === null || known.has(field);
|
|
770
777
|
const out = {
|
|
@@ -782,21 +789,170 @@ function uniqueryFilterToFieldFilters(expr, knownFields, onUnsupportedFilter = w
|
|
|
782
789
|
}];
|
|
783
790
|
}
|
|
784
791
|
const positives = /* @__PURE__ */ new Map();
|
|
792
|
+
const unknown = /* @__PURE__ */ new Map();
|
|
785
793
|
for (const term of out.terms) {
|
|
786
|
-
if (!isKnown(term.field))
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
794
|
+
if (!isKnown(term.field)) {
|
|
795
|
+
unknown.set(term.expr, [term.field]);
|
|
796
|
+
continue;
|
|
797
|
+
}
|
|
798
|
+
const list = filters[term.field] ??= [];
|
|
799
|
+
if (isNegative(term)) {
|
|
791
800
|
list.push(...term.conds);
|
|
792
|
-
|
|
801
|
+
continue;
|
|
802
|
+
}
|
|
803
|
+
const groups = positives.get(term.field);
|
|
804
|
+
if (!groups) {
|
|
805
|
+
positives.set(term.field, [term]);
|
|
806
|
+
list.push(...term.conds);
|
|
807
|
+
} else if (!groups.some((g) => sameConds(g.conds, term.conds))) groups.push(term);
|
|
808
|
+
}
|
|
809
|
+
const carried = [];
|
|
810
|
+
for (const [field, groups] of positives) {
|
|
811
|
+
if (groups.length < 2) continue;
|
|
812
|
+
const leftOut = opts.carry ? groups : groups.slice(1);
|
|
813
|
+
if (opts.carry) {
|
|
814
|
+
const kept = new Set(groups[0].conds);
|
|
815
|
+
const rest = filters[field].filter((c) => !kept.has(c));
|
|
816
|
+
if (rest.length > 0) filters[field] = rest;
|
|
817
|
+
else delete filters[field];
|
|
818
|
+
}
|
|
819
|
+
for (const term of leftOut) out.issues.push({
|
|
793
820
|
reason: "conjunction",
|
|
794
821
|
expr: term.expr,
|
|
795
|
-
fields: [
|
|
822
|
+
fields: [field]
|
|
796
823
|
});
|
|
797
824
|
}
|
|
798
|
-
for (const issue of out.issues)
|
|
799
|
-
|
|
825
|
+
for (const issue of out.issues) {
|
|
826
|
+
const fields = issue.fields;
|
|
827
|
+
const outside = fields.filter((f) => !isKnown(f));
|
|
828
|
+
if (outside.length > 0) unknown.set(issue.expr, outside);
|
|
829
|
+
else if (opts.carry && fields.length > 0) carried.push(issue.expr);
|
|
830
|
+
else result.unsupported.push(issue);
|
|
831
|
+
}
|
|
832
|
+
result.residual = normalizeResidualFilters(carried);
|
|
833
|
+
result.unknown = [...unknown].map(([expr, fields]) => ({
|
|
834
|
+
expr,
|
|
835
|
+
fields
|
|
836
|
+
}));
|
|
837
|
+
return result;
|
|
838
|
+
}
|
|
839
|
+
/**
|
|
840
|
+
* Canonical identity of a filter expression — its `@uniqu/url` spelling.
|
|
841
|
+
* Two expressions with the same key select the same rows.
|
|
842
|
+
*
|
|
843
|
+
* @since 0.1.140
|
|
844
|
+
*/
|
|
845
|
+
function filterExprKey(expr) {
|
|
846
|
+
try {
|
|
847
|
+
return buildUrl({ filter: expr });
|
|
848
|
+
} catch {
|
|
849
|
+
return JSON.stringify(expr) ?? "";
|
|
850
|
+
}
|
|
851
|
+
}
|
|
852
|
+
/**
|
|
853
|
+
* A residual-condition list with empty expressions and duplicates (by
|
|
854
|
+
* {@link filterExprKey}) dropped, sorted by key. Sorted, not first-appearance:
|
|
855
|
+
* the URL parser moves \`$not\`-wrapped clauses (how \`mergeFilters\` spells
|
|
856
|
+
* a repeated same-field clause) ahead of plain ones, so appearance order would
|
|
857
|
+
* flip on every round trip.
|
|
858
|
+
*
|
|
859
|
+
* @internal Exported for `@atscript/vue-table`.
|
|
860
|
+
*/
|
|
861
|
+
function normalizeResidualFilters(exprs) {
|
|
862
|
+
const byKey = /* @__PURE__ */ new Map();
|
|
863
|
+
for (const expr of exprs) {
|
|
864
|
+
if (!isPlainObject(expr)) continue;
|
|
865
|
+
const key = filterExprKey(expr);
|
|
866
|
+
if (key && !byKey.has(key)) byKey.set(key, expr);
|
|
867
|
+
}
|
|
868
|
+
return [...byKey.keys()].toSorted().map((key) => byKey.get(key));
|
|
869
|
+
}
|
|
870
|
+
/**
|
|
871
|
+
* Convert a Uniquery `FilterExpr` back into the UI's `FieldFilters` shape.
|
|
872
|
+
*
|
|
873
|
+
* Inverse of `filtersToUniqueryFilter`, and exact for everything that encoder
|
|
874
|
+
* produces. For any other input, each AND-ed piece is either converted exactly
|
|
875
|
+
* or left out whole and reported — never approximated:
|
|
876
|
+
*
|
|
877
|
+
* - `$in` becomes equality conditions on the field, `$nin` inequality ones.
|
|
878
|
+
* - A same-field `$or` becomes that field's OR'd conditions.
|
|
879
|
+
* - A `$not` that inverts equality / emptiness becomes the inverse conditions.
|
|
880
|
+
* - Fields next to `$and` / `$or` / `$not` in one object are all kept.
|
|
881
|
+
* - Anything else (see {@link UnsupportedFilterReason}) is left out. Leaving
|
|
882
|
+
* an AND-ed piece out only ever widens the match, so the result selects a
|
|
883
|
+
* superset of `expr`. Each left-out piece goes to `onUnsupportedFilter`, or
|
|
884
|
+
* to a dev-mode `console.warn` when no handler is given. To keep those
|
|
885
|
+
* pieces instead, use {@link decomposeUniqueryFilter} with `carry`.
|
|
886
|
+
*
|
|
887
|
+
* Pieces that name a field outside `knownFields` (when provided) are ignored
|
|
888
|
+
* silently: they are not this table's (a host page flag, a hidden or stale
|
|
889
|
+
* column). Since 0.1.141 that includes a piece mixing known and unknown
|
|
890
|
+
* fields — {@link decomposeUniqueryFilter} lists them as `unknown`.
|
|
891
|
+
*
|
|
892
|
+
* Returns `{}` for an empty/missing expression. Never throws.
|
|
893
|
+
*/
|
|
894
|
+
function uniqueryFilterToFieldFilters(expr, knownFields, onUnsupportedFilter = warnUnsupported) {
|
|
895
|
+
const { filters, unsupported } = decomposeUniqueryFilter(expr, { knownFields });
|
|
896
|
+
for (const issue of unsupported) onUnsupportedFilter(issue);
|
|
897
|
+
return filters;
|
|
898
|
+
}
|
|
899
|
+
//#endregion
|
|
900
|
+
//#region src/filters/format-filter-expr.ts
|
|
901
|
+
function show(v) {
|
|
902
|
+
if (v instanceof Date) return v.toISOString();
|
|
903
|
+
if (typeof v === "object" && v !== null) return JSON.stringify(v) ?? "";
|
|
904
|
+
return String(v);
|
|
905
|
+
}
|
|
906
|
+
/** One comparison, worded like the filter chips (the decoder's operator table). */
|
|
907
|
+
function leaf(label, op, value) {
|
|
908
|
+
if (op === "$eq" && value instanceof RegExp) op = "$regex";
|
|
909
|
+
if ((op === "$in" || op === "$nin") && Array.isArray(value)) return `${label} ${op === "$in" ? "is one of" : "is none of"} ${value.map(show).join(", ")}`;
|
|
910
|
+
const conds = decodeOperator(op, value);
|
|
911
|
+
if (conds?.length === 1) return filterTokenLabel(label, conds, label);
|
|
912
|
+
return buildUrl({ filter: { [label]: { [op]: value } } }) || `${label}${op}`;
|
|
913
|
+
}
|
|
914
|
+
function join(children, kind) {
|
|
915
|
+
const parts = children.filter((c) => c.s);
|
|
916
|
+
if (parts.length === 0) return {
|
|
917
|
+
s: "",
|
|
918
|
+
kind: "leaf"
|
|
919
|
+
};
|
|
920
|
+
if (parts.length === 1) return parts[0];
|
|
921
|
+
const wrap = kind === "and" ? "or" : "and";
|
|
922
|
+
return {
|
|
923
|
+
s: parts.map((c) => c.kind === wrap ? `(${c.s})` : c.s).join(` ${kind} `),
|
|
924
|
+
kind
|
|
925
|
+
};
|
|
926
|
+
}
|
|
927
|
+
/**
|
|
928
|
+
* Human-readable rendering of a Uniquery filter expression, worded like the
|
|
929
|
+
* filter-field chips: `(Status equals shipped and Total greater than 500) or
|
|
930
|
+
* (Status equals pending and Total less or equal 50)`. `and` binds tighter
|
|
931
|
+
* than `or`; groups are parenthesized only where needed. Operators without a
|
|
932
|
+
* wording fall back to their `@uniqu/url` spelling.
|
|
933
|
+
*
|
|
934
|
+
* @param labelOf — display label for a field path (e.g. the column label);
|
|
935
|
+
* the path itself when omitted or when it returns `undefined`.
|
|
936
|
+
* @since 0.1.140
|
|
937
|
+
*/
|
|
938
|
+
function formatFilterExpr(expr, labelOf) {
|
|
939
|
+
const visitor = {
|
|
940
|
+
comparison: (field, op, value) => ({
|
|
941
|
+
s: leaf(labelOf?.(field) ?? field, op, value),
|
|
942
|
+
kind: "leaf"
|
|
943
|
+
}),
|
|
944
|
+
and: (children) => join(children, "and"),
|
|
945
|
+
or: (children) => join(children, "or"),
|
|
946
|
+
not: (child) => ({
|
|
947
|
+
s: child.s ? `not (${child.s})` : "",
|
|
948
|
+
kind: "leaf"
|
|
949
|
+
})
|
|
950
|
+
};
|
|
951
|
+
try {
|
|
952
|
+
return walkFilter(expr, visitor)?.s ?? "";
|
|
953
|
+
} catch {
|
|
954
|
+
return JSON.stringify(expr) ?? "";
|
|
955
|
+
}
|
|
800
956
|
}
|
|
801
957
|
//#endregion
|
|
802
958
|
//#region src/filters/date-shortcuts.ts
|
|
@@ -1001,6 +1157,137 @@ function resolveSystemPresets(input) {
|
|
|
1001
1157
|
}, ...named];
|
|
1002
1158
|
}
|
|
1003
1159
|
//#endregion
|
|
1160
|
+
//#region src/presets/prune-preset-snapshot.ts
|
|
1161
|
+
/** `[kept, dropped]` in one pass. */
|
|
1162
|
+
function partition(list, keep) {
|
|
1163
|
+
const kept = [];
|
|
1164
|
+
const dropped = [];
|
|
1165
|
+
for (const x of list) (keep(x) ? kept : dropped).push(x);
|
|
1166
|
+
return [kept, dropped];
|
|
1167
|
+
}
|
|
1168
|
+
/** The columns aspect, an empty width map spelled by omission — the way capture writes it. */
|
|
1169
|
+
function columnsAspect(columnNames, columnWidths) {
|
|
1170
|
+
return columnWidths && Object.keys(columnWidths).length > 0 ? {
|
|
1171
|
+
columnNames,
|
|
1172
|
+
columnWidths
|
|
1173
|
+
} : { columnNames };
|
|
1174
|
+
}
|
|
1175
|
+
/**
|
|
1176
|
+
* Remove every entry of `snapshot` that names a field outside `known`, per
|
|
1177
|
+
* aspect:
|
|
1178
|
+
*
|
|
1179
|
+
* - `columns.columnNames` — unknown names go, the rest keep their order. When
|
|
1180
|
+
* none survives, it falls back to every column, never an empty grid.
|
|
1181
|
+
* - `columns.columnWidths` — unknown keys go.
|
|
1182
|
+
* - `filters` (displayed inputs) and `filterOps` — entries on a field outside
|
|
1183
|
+
* `known.server` go. A field's conditions are one AND-ed conjunct, so
|
|
1184
|
+
* dropping one only broadens the result.
|
|
1185
|
+
* - `sorters` — unknown entries go, the rest keep their priority.
|
|
1186
|
+
* - `itemsPerPage` — untouched.
|
|
1187
|
+
*
|
|
1188
|
+
* `dropped` is `null` when nothing user-visible went (a width-only drop is
|
|
1189
|
+
* hygiene). The input is never mutated.
|
|
1190
|
+
*
|
|
1191
|
+
* @since 0.1.141
|
|
1192
|
+
*/
|
|
1193
|
+
function prunePresetSnapshot(snapshot, known) {
|
|
1194
|
+
const isColumn = (p) => known.columns.has(p);
|
|
1195
|
+
const isServer = (p) => known.server.has(p);
|
|
1196
|
+
const out = { ...snapshot };
|
|
1197
|
+
const dropped = {
|
|
1198
|
+
fields: [],
|
|
1199
|
+
columns: [],
|
|
1200
|
+
filterFields: [],
|
|
1201
|
+
filters: {},
|
|
1202
|
+
residual: [],
|
|
1203
|
+
sorters: []
|
|
1204
|
+
};
|
|
1205
|
+
if (snapshot.columns) {
|
|
1206
|
+
const [names, gone] = partition(snapshot.columns.columnNames, isColumn);
|
|
1207
|
+
const [widths] = partition(Object.entries(snapshot.columns.columnWidths ?? {}), ([p]) => isColumn(p));
|
|
1208
|
+
dropped.columns = gone;
|
|
1209
|
+
out.columns = columnsAspect(names.length > 0 || gone.length === 0 ? names : [...known.columns], Object.fromEntries(widths));
|
|
1210
|
+
}
|
|
1211
|
+
if (snapshot.filters) [out.filters, dropped.filterFields] = partition(snapshot.filters, isServer);
|
|
1212
|
+
if (snapshot.filterOps) {
|
|
1213
|
+
const [kept, gone] = partition(Object.entries(snapshot.filterOps), ([p]) => isServer(p));
|
|
1214
|
+
out.filterOps = Object.fromEntries(kept);
|
|
1215
|
+
dropped.filters = Object.fromEntries(gone);
|
|
1216
|
+
}
|
|
1217
|
+
if (snapshot.sorters) [out.sorters, dropped.sorters] = partition(snapshot.sorters, (s) => isColumn(s.field));
|
|
1218
|
+
dropped.fields = [...new Set([
|
|
1219
|
+
...dropped.columns,
|
|
1220
|
+
...dropped.filterFields,
|
|
1221
|
+
...Object.keys(dropped.filters),
|
|
1222
|
+
...dropped.sorters.map((s) => s.field)
|
|
1223
|
+
])];
|
|
1224
|
+
return {
|
|
1225
|
+
snapshot: out,
|
|
1226
|
+
dropped: dropped.fields.length > 0 ? dropped : null
|
|
1227
|
+
};
|
|
1228
|
+
}
|
|
1229
|
+
/**
|
|
1230
|
+
* Split residual filter conditions, in one pass, into the ones that name
|
|
1231
|
+
* only `known` fields and the ones that do not. A condition naming any
|
|
1232
|
+
* unknown field is dropped WHOLE — never pruned inside an `$or` (that would
|
|
1233
|
+
* narrow the result) or a `$not` (that would invert it). Each condition is an
|
|
1234
|
+
* AND-ed conjunct, so dropping one only broadens the result.
|
|
1235
|
+
*
|
|
1236
|
+
* `dropped` holds the conditions in `residual` and the unknown paths in
|
|
1237
|
+
* `fields`; `null` when every condition is kept.
|
|
1238
|
+
*
|
|
1239
|
+
* @since 0.1.141
|
|
1240
|
+
*/
|
|
1241
|
+
function pruneResidualFilters(exprs, known) {
|
|
1242
|
+
const kept = [];
|
|
1243
|
+
const residual = [];
|
|
1244
|
+
const fields = /* @__PURE__ */ new Set();
|
|
1245
|
+
for (const expr of exprs) {
|
|
1246
|
+
const unknown = filterExprFields(expr).filter((p) => !known.has(p));
|
|
1247
|
+
if (unknown.length === 0) {
|
|
1248
|
+
kept.push(expr);
|
|
1249
|
+
continue;
|
|
1250
|
+
}
|
|
1251
|
+
residual.push(expr);
|
|
1252
|
+
for (const p of unknown) fields.add(p);
|
|
1253
|
+
}
|
|
1254
|
+
if (residual.length === 0) return {
|
|
1255
|
+
kept,
|
|
1256
|
+
dropped: null
|
|
1257
|
+
};
|
|
1258
|
+
return {
|
|
1259
|
+
kept,
|
|
1260
|
+
dropped: {
|
|
1261
|
+
fields: [...fields],
|
|
1262
|
+
columns: [],
|
|
1263
|
+
filterFields: [],
|
|
1264
|
+
filters: {},
|
|
1265
|
+
residual,
|
|
1266
|
+
sorters: []
|
|
1267
|
+
}
|
|
1268
|
+
};
|
|
1269
|
+
}
|
|
1270
|
+
/**
|
|
1271
|
+
* Append what a prune dropped back onto a snapshot captured from (pruned)
|
|
1272
|
+
* table state, so overwriting a preset with a narrower view does not destroy
|
|
1273
|
+
* the parts the saver cannot see. Column names, filter inputs and sorters go
|
|
1274
|
+
* after the captured ones; field filters are added back. Only aspects
|
|
1275
|
+
* `captured` carries are touched; widths of hidden columns are not kept.
|
|
1276
|
+
*
|
|
1277
|
+
* @since 0.1.141
|
|
1278
|
+
*/
|
|
1279
|
+
function restoreDroppedEntries(captured, dropped) {
|
|
1280
|
+
const out = { ...captured };
|
|
1281
|
+
if (captured.columns) out.columns = columnsAspect([...captured.columns.columnNames, ...dropped.columns], captured.columns.columnWidths);
|
|
1282
|
+
if (captured.filters) out.filters = [...captured.filters, ...dropped.filterFields];
|
|
1283
|
+
if (captured.filterOps) out.filterOps = {
|
|
1284
|
+
...captured.filterOps,
|
|
1285
|
+
...dropped.filters
|
|
1286
|
+
};
|
|
1287
|
+
if (captured.sorters) out.sorters = [...captured.sorters, ...dropped.sorters];
|
|
1288
|
+
return out;
|
|
1289
|
+
}
|
|
1290
|
+
//#endregion
|
|
1004
1291
|
//#region src/presets/preset-dirty.ts
|
|
1005
1292
|
/**
|
|
1006
1293
|
* JSON-stringify with object keys sorted alphabetically at every depth so
|
|
@@ -1393,8 +1680,9 @@ function mergeSorters(forceSorters, userSorters) {
|
|
|
1393
1680
|
//#endregion
|
|
1394
1681
|
//#region src/query/merge-filters.ts
|
|
1395
1682
|
/**
|
|
1396
|
-
* AND-merge
|
|
1397
|
-
* `@uniqu/url`'s `mergeConjunction` parser collapse.
|
|
1683
|
+
* AND-merge filter expressions (`undefined` ones skipped), producing a wire
|
|
1684
|
+
* shape that survives `@uniqu/url`'s `mergeConjunction` parser collapse.
|
|
1685
|
+
* Variadic since 0.1.140.
|
|
1398
1686
|
*
|
|
1399
1687
|
* The collapse problem: when two `$and` siblings target the same field
|
|
1400
1688
|
* with the same op (e.g. `{status: 'cancelled'}` AND `{status: 'shipped'}`),
|
|
@@ -1407,10 +1695,10 @@ function mergeSorters(forceSorters, userSorters) {
|
|
|
1407
1695
|
* and `!!p ≡ p` is a semantic identity, so the server evaluator sees the
|
|
1408
1696
|
* same AND. Non-colliding merges produce the canonical `$and` shape.
|
|
1409
1697
|
*/
|
|
1410
|
-
function mergeFilters(
|
|
1411
|
-
|
|
1412
|
-
if (
|
|
1413
|
-
return makeParserSafeAnd(
|
|
1698
|
+
function mergeFilters(...exprs) {
|
|
1699
|
+
const list = exprs.filter((e) => !!e);
|
|
1700
|
+
if (list.length <= 1) return list[0];
|
|
1701
|
+
return makeParserSafeAnd(list);
|
|
1414
1702
|
}
|
|
1415
1703
|
/** Op-set for a field value: primitives are `$eq`, op-bags expose their keys. */
|
|
1416
1704
|
function getOpsForFieldValue(value) {
|
|
@@ -1478,12 +1766,12 @@ function makeParserSafeAnd(children) {
|
|
|
1478
1766
|
* Build a Uniquery object from table UI state.
|
|
1479
1767
|
*
|
|
1480
1768
|
* Pure function — no framework dependencies.
|
|
1481
|
-
* Combines user filters
|
|
1769
|
+
* Combines user filters (field filters, then residual conditions) with force
|
|
1770
|
+
* filters, merges sorters,
|
|
1482
1771
|
* projects visible columns, and applies pagination.
|
|
1483
1772
|
*/
|
|
1484
1773
|
function buildTableQuery(opts) {
|
|
1485
|
-
const
|
|
1486
|
-
const filter = mergeFilters(opts.forceFilters, userFilter);
|
|
1774
|
+
const filter = mergeFilters(opts.forceFilters, filtersToUniqueryFilter(opts.filters), ...opts.residualFilters ?? []);
|
|
1487
1775
|
const userSorters = opts.ignoreSorters ? [] : opts.sorters;
|
|
1488
1776
|
const sorters = opts.forceSorters?.length ? mergeSorters(opts.forceSorters, userSorters) : userSorters;
|
|
1489
1777
|
const $sort = {};
|
|
@@ -1543,6 +1831,17 @@ function gateOwns(gate, path) {
|
|
|
1543
1831
|
if (gate === "none") return false;
|
|
1544
1832
|
return gate.has(path);
|
|
1545
1833
|
}
|
|
1834
|
+
/**
|
|
1835
|
+
* Does a URL under `gate` own the residual condition `expr`? Only when it
|
|
1836
|
+
* owns every field the condition references — a condition that touches a
|
|
1837
|
+
* private field stays private as a whole.
|
|
1838
|
+
*
|
|
1839
|
+
* @internal Exported for `@atscript/vue-table`.
|
|
1840
|
+
*/
|
|
1841
|
+
function residualGateOwns(gate, expr) {
|
|
1842
|
+
const fields = filterExprFields(expr);
|
|
1843
|
+
return fields.length > 0 && fields.every((path) => gateOwns(gate, path));
|
|
1844
|
+
}
|
|
1546
1845
|
function pickFilterPaths(filters, gate) {
|
|
1547
1846
|
const out = {};
|
|
1548
1847
|
for (const path in filters) if (gateOwns(gate, path)) out[path] = filters[path];
|
|
@@ -1569,6 +1868,7 @@ function stateToUrlQueryString(state, defaults) {
|
|
|
1569
1868
|
visibleColumnPaths: [],
|
|
1570
1869
|
sorters: sortersGate === "all" ? state.sorters : state.sorters.filter((s) => gateOwns(sortersGate, s.field)),
|
|
1571
1870
|
filters,
|
|
1871
|
+
residualFilters: defaults.sync?.residual === false || !state.residualFilters?.length ? void 0 : state.residualFilters.filter((expr) => residualGateOwns(filtersGate, expr)),
|
|
1572
1872
|
search: searchOff ? void 0 : state.searchTerm || void 0
|
|
1573
1873
|
});
|
|
1574
1874
|
if (!searchOff && state.searchTerm && state.ignoreSorters !== void 0 && state.ignoreSorters !== (defaults.defaultIgnoreSorters ?? false)) query.controls.$relevance = state.ignoreSorters ? 1 : 0;
|
|
@@ -1591,8 +1891,25 @@ const CONSUMED_CONTROLS = new Set([
|
|
|
1591
1891
|
"$skip",
|
|
1592
1892
|
URL_SNAPSHOT_KEY
|
|
1593
1893
|
]);
|
|
1594
|
-
|
|
1595
|
-
|
|
1894
|
+
function toSet(paths) {
|
|
1895
|
+
if (!paths) return null;
|
|
1896
|
+
return paths instanceof Set ? paths : new Set(paths);
|
|
1897
|
+
}
|
|
1898
|
+
/**
|
|
1899
|
+
* `field=value` (or `field=null`) — the one filter shape a host page flag
|
|
1900
|
+
* shares, so a bare one on an unknown field is not reported as dropped.
|
|
1901
|
+
*/
|
|
1902
|
+
function isBareEquality(expr) {
|
|
1903
|
+
const keys = Object.keys(expr);
|
|
1904
|
+
if (keys.length !== 1 || keys[0].startsWith("$")) return false;
|
|
1905
|
+
const v = expr[keys[0]];
|
|
1906
|
+
return v === null || typeof v !== "object";
|
|
1907
|
+
}
|
|
1908
|
+
/**
|
|
1909
|
+
* Characters that can only appear in a uniqu filter key, never in a page flag:
|
|
1910
|
+
* comparison operators, `^` (OR), `( )` (groups) and `{ }` (`$in` lists).
|
|
1911
|
+
*/
|
|
1912
|
+
const FILTER_OPERATOR_CHAR = /[<>!~()^{}]/;
|
|
1596
1913
|
/**
|
|
1597
1914
|
* Whether {@link urlQueryStringToState} would consume the query key `key` —
|
|
1598
1915
|
* i.e. whether the key belongs to the table rather than to the page hosting
|
|
@@ -1614,12 +1931,15 @@ function urlQueryConsumesKey(key) {
|
|
|
1614
1931
|
/**
|
|
1615
1932
|
* Parse a URL query string back into the table state subset.
|
|
1616
1933
|
*
|
|
1617
|
-
* Robust by design — schema drift
|
|
1618
|
-
* recipient's view:
|
|
1619
|
-
* - unknown fields (not in `knownFields`) →
|
|
1934
|
+
* Robust by design — schema drift, fields hidden from the recipient and
|
|
1935
|
+
* copy-paste errors must not break the recipient's view:
|
|
1936
|
+
* - unknown fields (not in `knownFields`) → dropped, listed in `unknown` /
|
|
1937
|
+
* `unknownSorters` (the parser does not warn — the caller decides)
|
|
1620
1938
|
* - filter pieces field filters cannot express (cross-field OR, unknown
|
|
1621
1939
|
* operator, …) → left out of `filters` and listed in `unsupported`, never
|
|
1622
|
-
* approximated (the parser does not warn — the caller decides)
|
|
1940
|
+
* approximated (the parser does not warn — the caller decides). Unless
|
|
1941
|
+
* `sync.residual` is `false`, those whose fields are all known come back
|
|
1942
|
+
* in `residual` instead
|
|
1623
1943
|
* - unknown controls (e.g. `$weird=42`) → silently ignored
|
|
1624
1944
|
* - malformed query → `{ filters: {}, sorters: [], searchTerm: "" }`
|
|
1625
1945
|
*
|
|
@@ -1647,30 +1967,35 @@ function urlQueryStringToState(urlString, opts = {}) {
|
|
|
1647
1967
|
const sortersGate = resolveAspectGate(opts.sync?.sorters);
|
|
1648
1968
|
const searchOff = opts.sync?.search === false;
|
|
1649
1969
|
const paginationOff = opts.sync?.pagination === false;
|
|
1650
|
-
const knownSet =
|
|
1970
|
+
const knownSet = toSet(opts.knownFields);
|
|
1971
|
+
const localSet = toSet(opts.localFields);
|
|
1972
|
+
const isHidden = (f) => !!knownSet && !knownSet.has(f) && !localSet?.has(f);
|
|
1651
1973
|
let filterKnown;
|
|
1652
1974
|
if (filtersGate === "all") filterKnown = knownSet ?? void 0;
|
|
1653
1975
|
else if (filtersGate !== "none") if (knownSet) {
|
|
1654
|
-
|
|
1655
|
-
for (const path of filtersGate) if (knownSet.has(path))
|
|
1976
|
+
const owned = /* @__PURE__ */ new Set();
|
|
1977
|
+
for (const path of filtersGate) if (knownSet.has(path)) owned.add(path);
|
|
1978
|
+
filterKnown = owned;
|
|
1656
1979
|
} else filterKnown = filtersGate;
|
|
1657
|
-
const
|
|
1658
|
-
|
|
1980
|
+
const decomposed = filtersGate === "none" ? null : decomposeUniqueryFilter(parsed.filter, {
|
|
1981
|
+
knownFields: filterKnown,
|
|
1982
|
+
carry: opts.sync?.residual !== false
|
|
1983
|
+
});
|
|
1984
|
+
const filters = decomposed?.filters ?? {};
|
|
1659
1985
|
const sorters = [];
|
|
1986
|
+
const unknownSorters = [];
|
|
1660
1987
|
if (sortersGate !== "none") {
|
|
1661
1988
|
const $sort = parsed.controls?.$sort;
|
|
1662
1989
|
if ($sort && typeof $sort === "object") for (const field in $sort) {
|
|
1663
|
-
if (knownSet && !knownSet.has(field)) continue;
|
|
1664
1990
|
if (!gateOwns(sortersGate, field)) continue;
|
|
1665
1991
|
const dir = $sort[field];
|
|
1666
|
-
if (dir
|
|
1992
|
+
if (dir !== 1 && dir !== -1) continue;
|
|
1993
|
+
const sorter = {
|
|
1667
1994
|
field,
|
|
1668
|
-
direction: "asc"
|
|
1669
|
-
}
|
|
1670
|
-
|
|
1671
|
-
|
|
1672
|
-
direction: "desc"
|
|
1673
|
-
});
|
|
1995
|
+
direction: dir === 1 ? "asc" : "desc"
|
|
1996
|
+
};
|
|
1997
|
+
if (!knownSet || knownSet.has(field)) sorters.push(sorter);
|
|
1998
|
+
else if (isHidden(field)) unknownSorters.push(sorter);
|
|
1674
1999
|
}
|
|
1675
2000
|
}
|
|
1676
2001
|
const $search = parsed.controls?.$search;
|
|
@@ -1679,7 +2004,24 @@ function urlQueryStringToState(urlString, opts = {}) {
|
|
|
1679
2004
|
sorters,
|
|
1680
2005
|
searchTerm: !searchOff && typeof $search === "string" ? $search : ""
|
|
1681
2006
|
};
|
|
1682
|
-
|
|
2007
|
+
const unsupported$1 = decomposed?.unsupported ?? [];
|
|
2008
|
+
const unknown = [];
|
|
2009
|
+
for (const piece of decomposed?.unknown ?? []) {
|
|
2010
|
+
const hidden = piece.fields.filter(isHidden);
|
|
2011
|
+
if (hidden.length > 0) {
|
|
2012
|
+
if (!isBareEquality(piece.expr)) unknown.push({
|
|
2013
|
+
expr: piece.expr,
|
|
2014
|
+
fields: hidden
|
|
2015
|
+
});
|
|
2016
|
+
continue;
|
|
2017
|
+
}
|
|
2018
|
+
const issue = unsupported("cross-field", piece.expr);
|
|
2019
|
+
if (issue.fields.some((f) => filterKnown?.has(f))) unsupported$1.push(issue);
|
|
2020
|
+
}
|
|
2021
|
+
if (unsupported$1.length) out.unsupported = unsupported$1;
|
|
2022
|
+
if (decomposed?.residual.length) out.residual = decomposed.residual;
|
|
2023
|
+
if (unknown.length) out.unknown = unknown;
|
|
2024
|
+
if (unknownSorters.length) out.unknownSorters = unknownSorters;
|
|
1683
2025
|
if (opts.sync?.snapshot !== false && parsed.controls && "$snapshot" in parsed.controls) out.snapshot = true;
|
|
1684
2026
|
if (!searchOff) {
|
|
1685
2027
|
const $relevance = parsed.controls?.$relevance;
|
|
@@ -2231,4 +2573,4 @@ function sortRowsLocally(rows, sorters, getValue = (row, field) => row[field]) {
|
|
|
2231
2573
|
});
|
|
2232
2574
|
}
|
|
2233
2575
|
//#endregion
|
|
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 };
|
|
2576
|
+
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, decomposeUniqueryFilter, defaultCondition, derivePresetAspects, deserializeDraft, draftMatchesPreset, escapeRegex, filledFilterCount, filterExprFields, filterExprKey, filterTokenLabel, filtersToUniqueryFilter, formatFilterCondition, formatFilterExpr, fromWireSnapshot, gateOwns, hasSecondValue, isAuthError, isColumnFilterable, isDirtyAgainst, isEmptyDraft, isFilled, isSimpleEq, isSystemPresetId, isUnavailableError, mergeDisplayColumns, mergeFilters, mergeSorters, normaliseSystemPresetId, normalizeResidualFilters, pageAlignedBlocksFor, parseColumnFilterInput, parseFilterInput, planFetch, prunePresetSnapshot, pruneResidualFilters, reconcileColumnWidthDefaults, reorderColumnNames, residualGateOwns, resolveAspectGate, resolveExportValue, resolveSystemPresets, restoreDroppedEntries, rowsToPks, sameColumnSet, serializeDraft, setsEqual, sortRowsLocally, sortersEqual, stableStringify, stateToUrlQueryString, toCsv, toWireSnapshot, togglePk, trimSelection, unescapeRegex, uniqueryFilterToFieldFilters, urlQueryConsumesKey, urlQueryStringToState, userConfId, walkBackwardAbsorb, walkForwardAbsorb, withStableOrder };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@atscript/ui-table",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.141",
|
|
4
4
|
"description": "Framework-agnostic filter model, filter-to-Uniquery conversion, and preset serialization for atscript tables",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"atscript",
|
|
@@ -42,18 +42,18 @@
|
|
|
42
42
|
"access": "public"
|
|
43
43
|
},
|
|
44
44
|
"dependencies": {
|
|
45
|
-
"@atscript/ui": "0.1.
|
|
45
|
+
"@atscript/ui": "0.1.141"
|
|
46
46
|
},
|
|
47
47
|
"devDependencies": {
|
|
48
|
-
"@atscript/db-client": "^0.1.
|
|
49
|
-
"@uniqu/core": "^0.1.
|
|
50
|
-
"@uniqu/url": "^0.1.
|
|
48
|
+
"@atscript/db-client": "^0.1.133",
|
|
49
|
+
"@uniqu/core": "^0.1.10",
|
|
50
|
+
"@uniqu/url": "^0.1.10",
|
|
51
51
|
"vitest": "npm:@voidzero-dev/vite-plus-test@0.1.14"
|
|
52
52
|
},
|
|
53
53
|
"peerDependencies": {
|
|
54
|
-
"@atscript/db-client": "^0.1.
|
|
55
|
-
"@uniqu/core": "^0.1.
|
|
56
|
-
"@uniqu/url": "^0.1.
|
|
54
|
+
"@atscript/db-client": "^0.1.133",
|
|
55
|
+
"@uniqu/core": "^0.1.10",
|
|
56
|
+
"@uniqu/url": "^0.1.10"
|
|
57
57
|
},
|
|
58
58
|
"scripts": {
|
|
59
59
|
"build": "vp pack",
|