@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.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
- /** Field paths an expression references, deduped, in order of appearance. */
512
- function fieldsOf(expr, out = []) {
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
- * Convert a Uniquery `FilterExpr` back into the UI's `FieldFilters` shape.
745
- *
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.
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
- * 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.
761
+ * Never throws, never warns — the caller decides how to report.
762
762
  *
763
- * Returns `{}` for an empty/missing expression. Never throws.
763
+ * @since 0.1.140
764
764
  */
765
- function uniqueryFilterToFieldFilters(expr, knownFields, onUnsupportedFilter = warnUnsupported) {
766
- const acc = {};
767
- if (!expr) return acc;
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)) 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);
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
- } else if (!sameConds(kept, term.conds)) out.issues.push({
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: [term.field]
822
+ fields: [field]
796
823
  });
797
824
  }
798
- for (const issue of out.issues) if (issue.fields.length === 0 || issue.fields.some(isKnown)) onUnsupportedFilter(issue);
799
- return acc;
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 two filter expressions, producing a wire shape that survives
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(a, b) {
1411
- if (!a) return b;
1412
- if (!b) return a;
1413
- return makeParserSafeAnd([a, b]);
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 with force filters, merges sorters,
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 userFilter = filtersToUniqueryFilter(opts.filters);
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
- /** Characters that can only appear in a uniqu filter key, never in a page flag. */
1595
- const FILTER_OPERATOR_CHAR = /[<>!~]/;
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 and copy-paste errors must not break the
1618
- * recipient's view:
1619
- * - unknown fields (not in `knownFields`) → silently dropped
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 = opts.knownFields ? new Set(opts.knownFields) : null;
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
- filterKnown = /* @__PURE__ */ new Set();
1655
- for (const path of filtersGate) if (knownSet.has(path)) filterKnown.add(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 unsupported = [];
1658
- const filters = filtersGate === "none" ? {} : uniqueryFilterToFieldFilters(parsed.filter, filterKnown, (issue) => unsupported.push(issue));
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 === 1) sorters.push({
1992
+ if (dir !== 1 && dir !== -1) continue;
1993
+ const sorter = {
1667
1994
  field,
1668
- direction: "asc"
1669
- });
1670
- else if (dir === -1) sorters.push({
1671
- field,
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
- if (unsupported.length > 0) out.unsupported = unsupported;
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.139",
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.139"
45
+ "@atscript/ui": "0.1.141"
46
46
  },
47
47
  "devDependencies": {
48
- "@atscript/db-client": "^0.1.132",
49
- "@uniqu/core": "^0.1.9",
50
- "@uniqu/url": "^0.1.9",
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.132",
55
- "@uniqu/core": "^0.1.9",
56
- "@uniqu/url": "^0.1.9"
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",