@atscript/ui-table 0.1.140 → 0.1.142

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 CHANGED
@@ -46,6 +46,19 @@ function filledFilterCount(filters) {
46
46
  for (const path in filters) if (filters[path].some(isFilled)) count++;
47
47
  return count;
48
48
  }
49
+ /**
50
+ * `filters` without the fields that have no filled condition — the shape
51
+ * table state holds (a field with nothing filled has no entry). Returns
52
+ * `filters` itself when every field has one; never mutates it.
53
+ *
54
+ * @since 0.1.142
55
+ */
56
+ function compactFieldFilters(filters) {
57
+ if (filledFilterCount(filters) === Object.keys(filters).length) return filters;
58
+ const out = {};
59
+ for (const path in filters) if (filters[path].some(isFilled)) out[path] = filters[path];
60
+ return out;
61
+ }
49
62
  /** Summarize a field's conditions into a human-readable token label. */
50
63
  function filterTokenLabel(path, conditions, columnLabel) {
51
64
  const filled = conditions.filter(isFilled);
@@ -528,6 +541,7 @@ function fieldsOf(expr, out) {
528
541
  else if (!out.includes(key)) out.push(key);
529
542
  return out;
530
543
  }
544
+ /** @internal Module-shared with the URL parser; not a package export. */
531
545
  function unsupported(reason, expr) {
532
546
  const fields = fieldsOf(expr, []);
533
547
  return {
@@ -753,10 +767,10 @@ function warnUnsupported(issue) {
753
767
  /**
754
768
  * Split a Uniquery `FilterExpr` into what the table's field-filter model
755
769
  * 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`.
770
+ * conditions (`residual`, with `carry`), what it leaves out (`unsupported`)
771
+ * and what names a field outside `knownFields` (`unknown`). Nothing is
772
+ * approximated: `filters AND residual` selects `expr` minus the
773
+ * `unsupported` and `unknown` pieces.
760
774
  *
761
775
  * Never throws, never warns — the caller decides how to report.
762
776
  *
@@ -767,7 +781,8 @@ function decomposeUniqueryFilter(expr, opts = {}) {
767
781
  const result = {
768
782
  filters,
769
783
  residual: [],
770
- unsupported: []
784
+ unsupported: [],
785
+ unknown: []
771
786
  };
772
787
  if (!expr) return result;
773
788
  const knownFields = opts.knownFields;
@@ -788,8 +803,12 @@ function decomposeUniqueryFilter(expr, opts = {}) {
788
803
  }];
789
804
  }
790
805
  const positives = /* @__PURE__ */ new Map();
806
+ const unknown = /* @__PURE__ */ new Map();
791
807
  for (const term of out.terms) {
792
- if (!isKnown(term.field)) continue;
808
+ if (!isKnown(term.field)) {
809
+ unknown.set(term.expr, [term.field]);
810
+ continue;
811
+ }
793
812
  const list = filters[term.field] ??= [];
794
813
  if (isNegative(term)) {
795
814
  list.push(...term.conds);
@@ -819,11 +838,16 @@ function decomposeUniqueryFilter(expr, opts = {}) {
819
838
  }
820
839
  for (const issue of out.issues) {
821
840
  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);
841
+ const outside = fields.filter((f) => !isKnown(f));
842
+ if (outside.length > 0) unknown.set(issue.expr, outside);
843
+ else if (opts.carry && fields.length > 0) carried.push(issue.expr);
824
844
  else result.unsupported.push(issue);
825
845
  }
826
846
  result.residual = normalizeResidualFilters(carried);
847
+ result.unknown = [...unknown].map(([expr, fields]) => ({
848
+ expr,
849
+ fields
850
+ }));
827
851
  return result;
828
852
  }
829
853
  /**
@@ -874,9 +898,10 @@ function normalizeResidualFilters(exprs) {
874
898
  * to a dev-mode `console.warn` when no handler is given. To keep those
875
899
  * pieces instead, use {@link decomposeUniqueryFilter} with `carry`.
876
900
  *
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.
901
+ * Pieces that name a field outside `knownFields` (when provided) are ignored
902
+ * silently: they are not this table's (a host page flag, a hidden or stale
903
+ * column). Since 0.1.141 that includes a piece mixing known and unknown
904
+ * fields — {@link decomposeUniqueryFilter} lists them as `unknown`.
880
905
  *
881
906
  * Returns `{}` for an empty/missing expression. Never throws.
882
907
  */
@@ -1146,6 +1171,176 @@ function resolveSystemPresets(input) {
1146
1171
  }, ...named];
1147
1172
  }
1148
1173
  //#endregion
1174
+ //#region src/presets/prune-preset-snapshot.ts
1175
+ /** `[kept, dropped]` in one pass. */
1176
+ function partition(list, keep) {
1177
+ const kept = [];
1178
+ const dropped = [];
1179
+ for (const x of list) (keep(x) ? kept : dropped).push(x);
1180
+ return [kept, dropped];
1181
+ }
1182
+ /** The columns aspect, an empty width map spelled by omission — the way capture writes it. */
1183
+ function columnsAspect(columnNames, columnWidths) {
1184
+ return columnWidths && Object.keys(columnWidths).length > 0 ? {
1185
+ columnNames,
1186
+ columnWidths
1187
+ } : { columnNames };
1188
+ }
1189
+ /**
1190
+ * Remove every entry of `snapshot` that names a field outside `known`, per
1191
+ * aspect:
1192
+ *
1193
+ * - `columns.columnNames` — unknown names go, the rest keep their order. When
1194
+ * none survives, it falls back to every column, never an empty grid.
1195
+ * - `columns.columnWidths` — unknown keys go.
1196
+ * - `filters` (displayed inputs) and `filterOps` — entries on a field outside
1197
+ * `known.server` go. A field's conditions are one AND-ed conjunct, so
1198
+ * dropping one only broadens the result.
1199
+ * - `sorters` — unknown entries go, the rest keep their priority.
1200
+ * - `itemsPerPage` — untouched.
1201
+ *
1202
+ * `dropped` is `null` when nothing user-visible went (a width-only drop is
1203
+ * hygiene). The input is never mutated.
1204
+ *
1205
+ * @since 0.1.141
1206
+ */
1207
+ function prunePresetSnapshot(snapshot, known) {
1208
+ const isColumn = (p) => known.columns.has(p);
1209
+ const isServer = (p) => known.server.has(p);
1210
+ const out = { ...snapshot };
1211
+ const dropped = {
1212
+ fields: [],
1213
+ columns: [],
1214
+ filterFields: [],
1215
+ filters: {},
1216
+ residual: [],
1217
+ sorters: []
1218
+ };
1219
+ if (snapshot.columns) {
1220
+ const [names, gone] = partition(snapshot.columns.columnNames, isColumn);
1221
+ const [widths] = partition(Object.entries(snapshot.columns.columnWidths ?? {}), ([p]) => isColumn(p));
1222
+ dropped.columns = gone;
1223
+ out.columns = columnsAspect(names.length > 0 || gone.length === 0 ? names : [...known.columns], Object.fromEntries(widths));
1224
+ }
1225
+ if (snapshot.filters) [out.filters, dropped.filterFields] = partition(snapshot.filters, isServer);
1226
+ if (snapshot.filterOps) {
1227
+ const [kept, gone] = partition(Object.entries(snapshot.filterOps), ([p]) => isServer(p));
1228
+ out.filterOps = Object.fromEntries(kept);
1229
+ dropped.filters = Object.fromEntries(gone);
1230
+ }
1231
+ if (snapshot.sorters) [out.sorters, dropped.sorters] = partition(snapshot.sorters, (s) => isColumn(s.field));
1232
+ dropped.fields = [...new Set([
1233
+ ...dropped.columns,
1234
+ ...dropped.filterFields,
1235
+ ...Object.keys(dropped.filters),
1236
+ ...dropped.sorters.map((s) => s.field)
1237
+ ])];
1238
+ return {
1239
+ snapshot: out,
1240
+ dropped: dropped.fields.length > 0 ? dropped : null
1241
+ };
1242
+ }
1243
+ /**
1244
+ * Spell a snapshot the way table state holds it, so it compares equal to a
1245
+ * capture of the state it produces:
1246
+ *
1247
+ * - `columns.columnWidths` — an entry equal to its column's default width, or
1248
+ * on a path with no default (not a column), goes; an emptied map is
1249
+ * omitted. Capture writes overrides only.
1250
+ * - `filterOps` — a field with no filled condition goes
1251
+ * (`compactFieldFilters`). Table state never holds one.
1252
+ *
1253
+ * `defaultWidths` maps each column path to its default width — the `d` of
1254
+ * its `ColumnWidthsMap` entry. Other aspects pass through. Returns `snapshot`
1255
+ * itself when nothing changes; the input is never mutated.
1256
+ *
1257
+ * @since 0.1.142
1258
+ */
1259
+ function canonicalPresetSnapshot(snapshot, defaultWidths) {
1260
+ let out = snapshot;
1261
+ const columns = snapshot.columns;
1262
+ if (columns?.columnWidths) {
1263
+ const entries = Object.entries(columns.columnWidths);
1264
+ const [kept] = partition(entries, ([p, w]) => {
1265
+ const d = defaultWidths[p];
1266
+ return d !== void 0 && d !== w;
1267
+ });
1268
+ if (kept.length !== entries.length || kept.length === 0) out = {
1269
+ ...out,
1270
+ columns: columnsAspect(columns.columnNames, Object.fromEntries(kept))
1271
+ };
1272
+ }
1273
+ if (snapshot.filterOps) {
1274
+ const filterOps = compactFieldFilters(snapshot.filterOps);
1275
+ if (filterOps !== snapshot.filterOps) out = {
1276
+ ...out,
1277
+ filterOps
1278
+ };
1279
+ }
1280
+ return out;
1281
+ }
1282
+ /**
1283
+ * Split residual filter conditions, in one pass, into the ones that name
1284
+ * only `known` fields and the ones that do not. A condition naming any
1285
+ * unknown field is dropped WHOLE — never pruned inside an `$or` (that would
1286
+ * narrow the result) or a `$not` (that would invert it). Each condition is an
1287
+ * AND-ed conjunct, so dropping one only broadens the result.
1288
+ *
1289
+ * `dropped` holds the conditions in `residual` and the unknown paths in
1290
+ * `fields`; `null` when every condition is kept.
1291
+ *
1292
+ * @since 0.1.141
1293
+ */
1294
+ function pruneResidualFilters(exprs, known) {
1295
+ const kept = [];
1296
+ const residual = [];
1297
+ const fields = /* @__PURE__ */ new Set();
1298
+ for (const expr of exprs) {
1299
+ const unknown = filterExprFields(expr).filter((p) => !known.has(p));
1300
+ if (unknown.length === 0) {
1301
+ kept.push(expr);
1302
+ continue;
1303
+ }
1304
+ residual.push(expr);
1305
+ for (const p of unknown) fields.add(p);
1306
+ }
1307
+ if (residual.length === 0) return {
1308
+ kept,
1309
+ dropped: null
1310
+ };
1311
+ return {
1312
+ kept,
1313
+ dropped: {
1314
+ fields: [...fields],
1315
+ columns: [],
1316
+ filterFields: [],
1317
+ filters: {},
1318
+ residual,
1319
+ sorters: []
1320
+ }
1321
+ };
1322
+ }
1323
+ /**
1324
+ * Append what a prune dropped back onto a snapshot captured from (pruned)
1325
+ * table state, so overwriting a preset with a narrower view does not destroy
1326
+ * the parts the saver cannot see. Column names, filter inputs and sorters go
1327
+ * after the captured ones; field filters are added back. Only aspects
1328
+ * `captured` carries are touched; widths of hidden columns are not kept.
1329
+ *
1330
+ * @since 0.1.141
1331
+ */
1332
+ function restoreDroppedEntries(captured, dropped) {
1333
+ const out = { ...captured };
1334
+ if (captured.columns) out.columns = columnsAspect([...captured.columns.columnNames, ...dropped.columns], captured.columns.columnWidths);
1335
+ if (captured.filters) out.filters = [...captured.filters, ...dropped.filterFields];
1336
+ if (captured.filterOps) out.filterOps = {
1337
+ ...captured.filterOps,
1338
+ ...dropped.filters
1339
+ };
1340
+ if (captured.sorters) out.sorters = [...captured.sorters, ...dropped.sorters];
1341
+ return out;
1342
+ }
1343
+ //#endregion
1149
1344
  //#region src/presets/preset-dirty.ts
1150
1345
  /**
1151
1346
  * JSON-stringify with object keys sorted alphabetically at every depth so
@@ -1749,6 +1944,20 @@ const CONSUMED_CONTROLS = new Set([
1749
1944
  "$skip",
1750
1945
  URL_SNAPSHOT_KEY
1751
1946
  ]);
1947
+ function toSet(paths) {
1948
+ if (!paths) return null;
1949
+ return paths instanceof Set ? paths : new Set(paths);
1950
+ }
1951
+ /**
1952
+ * `field=value` (or `field=null`) — the one filter shape a host page flag
1953
+ * shares, so a bare one on an unknown field is not reported as dropped.
1954
+ */
1955
+ function isBareEquality(expr) {
1956
+ const keys = Object.keys(expr);
1957
+ if (keys.length !== 1 || keys[0].startsWith("$")) return false;
1958
+ const v = expr[keys[0]];
1959
+ return v === null || typeof v !== "object";
1960
+ }
1752
1961
  /**
1753
1962
  * Characters that can only appear in a uniqu filter key, never in a page flag:
1754
1963
  * comparison operators, `^` (OR), `( )` (groups) and `{ }` (`$in` lists).
@@ -1775,9 +1984,10 @@ function urlQueryConsumesKey(key) {
1775
1984
  /**
1776
1985
  * Parse a URL query string back into the table state subset.
1777
1986
  *
1778
- * Robust by design — schema drift and copy-paste errors must not break the
1779
- * recipient's view:
1780
- * - unknown fields (not in `knownFields`) → silently dropped
1987
+ * Robust by design — schema drift, fields hidden from the recipient and
1988
+ * copy-paste errors must not break the recipient's view:
1989
+ * - unknown fields (not in `knownFields`) → dropped, listed in `unknown` /
1990
+ * `unknownSorters` (the parser does not warn — the caller decides)
1781
1991
  * - filter pieces field filters cannot express (cross-field OR, unknown
1782
1992
  * operator, …) → left out of `filters` and listed in `unsupported`, never
1783
1993
  * approximated (the parser does not warn — the caller decides). Unless
@@ -1810,12 +2020,15 @@ function urlQueryStringToState(urlString, opts = {}) {
1810
2020
  const sortersGate = resolveAspectGate(opts.sync?.sorters);
1811
2021
  const searchOff = opts.sync?.search === false;
1812
2022
  const paginationOff = opts.sync?.pagination === false;
1813
- const knownSet = opts.knownFields ? new Set(opts.knownFields) : null;
2023
+ const knownSet = toSet(opts.knownFields);
2024
+ const localSet = toSet(opts.localFields);
2025
+ const isHidden = (f) => !!knownSet && !knownSet.has(f) && !localSet?.has(f);
1814
2026
  let filterKnown;
1815
2027
  if (filtersGate === "all") filterKnown = knownSet ?? void 0;
1816
2028
  else if (filtersGate !== "none") if (knownSet) {
1817
- filterKnown = /* @__PURE__ */ new Set();
1818
- for (const path of filtersGate) if (knownSet.has(path)) filterKnown.add(path);
2029
+ const owned = /* @__PURE__ */ new Set();
2030
+ for (const path of filtersGate) if (knownSet.has(path)) owned.add(path);
2031
+ filterKnown = owned;
1819
2032
  } else filterKnown = filtersGate;
1820
2033
  const decomposed = filtersGate === "none" ? null : decomposeUniqueryFilter(parsed.filter, {
1821
2034
  knownFields: filterKnown,
@@ -1823,20 +2036,19 @@ function urlQueryStringToState(urlString, opts = {}) {
1823
2036
  });
1824
2037
  const filters = decomposed?.filters ?? {};
1825
2038
  const sorters = [];
2039
+ const unknownSorters = [];
1826
2040
  if (sortersGate !== "none") {
1827
2041
  const $sort = parsed.controls?.$sort;
1828
2042
  if ($sort && typeof $sort === "object") for (const field in $sort) {
1829
- if (knownSet && !knownSet.has(field)) continue;
1830
2043
  if (!gateOwns(sortersGate, field)) continue;
1831
2044
  const dir = $sort[field];
1832
- if (dir === 1) sorters.push({
2045
+ if (dir !== 1 && dir !== -1) continue;
2046
+ const sorter = {
1833
2047
  field,
1834
- direction: "asc"
1835
- });
1836
- else if (dir === -1) sorters.push({
1837
- field,
1838
- direction: "desc"
1839
- });
2048
+ direction: dir === 1 ? "asc" : "desc"
2049
+ };
2050
+ if (!knownSet || knownSet.has(field)) sorters.push(sorter);
2051
+ else if (isHidden(field)) unknownSorters.push(sorter);
1840
2052
  }
1841
2053
  }
1842
2054
  const $search = parsed.controls?.$search;
@@ -1845,8 +2057,24 @@ function urlQueryStringToState(urlString, opts = {}) {
1845
2057
  sorters,
1846
2058
  searchTerm: !searchOff && typeof $search === "string" ? $search : ""
1847
2059
  };
1848
- if (decomposed?.unsupported.length) out.unsupported = decomposed.unsupported;
2060
+ const unsupported$1 = decomposed?.unsupported ?? [];
2061
+ const unknown = [];
2062
+ for (const piece of decomposed?.unknown ?? []) {
2063
+ const hidden = piece.fields.filter(isHidden);
2064
+ if (hidden.length > 0) {
2065
+ if (!isBareEquality(piece.expr)) unknown.push({
2066
+ expr: piece.expr,
2067
+ fields: hidden
2068
+ });
2069
+ continue;
2070
+ }
2071
+ const issue = unsupported("cross-field", piece.expr);
2072
+ if (issue.fields.some((f) => filterKnown?.has(f))) unsupported$1.push(issue);
2073
+ }
2074
+ if (unsupported$1.length) out.unsupported = unsupported$1;
1849
2075
  if (decomposed?.residual.length) out.residual = decomposed.residual;
2076
+ if (unknown.length) out.unknown = unknown;
2077
+ if (unknownSorters.length) out.unknownSorters = unknownSorters;
1850
2078
  if (opts.sync?.snapshot !== false && parsed.controls && "$snapshot" in parsed.controls) out.snapshot = true;
1851
2079
  if (!searchOff) {
1852
2080
  const $relevance = parsed.controls?.$relevance;
@@ -2419,12 +2647,14 @@ exports.appConfId = appConfId;
2419
2647
  exports.arraysEqual = arraysEqual;
2420
2648
  exports.blockStartFor = blockStartFor;
2421
2649
  exports.buildTableQuery = buildTableQuery;
2650
+ exports.canonicalPresetSnapshot = canonicalPresetSnapshot;
2422
2651
  exports.cellAsString = cellAsString;
2423
2652
  exports.clampTopIndex = clampTopIndex;
2424
2653
  exports.collectExportRows = collectExportRows;
2425
2654
  exports.columnDefaultCondition = columnDefaultCondition;
2426
2655
  exports.columnFilterConditions = columnFilterConditions;
2427
2656
  exports.columnFilterType = columnFilterType;
2657
+ exports.compactFieldFilters = compactFieldFilters;
2428
2658
  exports.computeDefaultColumnWidth = computeDefaultColumnWidth;
2429
2659
  exports.conditionLabel = conditionLabel;
2430
2660
  exports.conditionsForType = conditionsForType;
@@ -2464,12 +2694,15 @@ exports.pageAlignedBlocksFor = pageAlignedBlocksFor;
2464
2694
  exports.parseColumnFilterInput = parseColumnFilterInput;
2465
2695
  exports.parseFilterInput = parseFilterInput;
2466
2696
  exports.planFetch = planFetch;
2697
+ exports.prunePresetSnapshot = prunePresetSnapshot;
2698
+ exports.pruneResidualFilters = pruneResidualFilters;
2467
2699
  exports.reconcileColumnWidthDefaults = reconcileColumnWidthDefaults;
2468
2700
  exports.reorderColumnNames = reorderColumnNames;
2469
2701
  exports.residualGateOwns = residualGateOwns;
2470
2702
  exports.resolveAspectGate = resolveAspectGate;
2471
2703
  exports.resolveExportValue = resolveExportValue;
2472
2704
  exports.resolveSystemPresets = resolveSystemPresets;
2705
+ exports.restoreDroppedEntries = restoreDroppedEntries;
2473
2706
  exports.rowsToPks = rowsToPks;
2474
2707
  exports.sameColumnSet = sameColumnSet;
2475
2708
  exports.serializeDraft = serializeDraft;