@atscript/ui-table 0.1.132 → 0.1.134

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
@@ -149,13 +149,22 @@ function unescapeRegex(input) {
149
149
  //#region src/filters/filter-input-format.ts
150
150
  /**
151
151
  * Coerce a raw string value to the appropriate JS type for the column.
152
- * Number columns get numeric values; everything else stays as string.
152
+ * Number columns get numeric values, boolean columns understand the
153
+ * `true` / `false` vocabulary (case-insensitive) so the condition carries a
154
+ * real boolean and round-trips through the URL as one. Anything else stays
155
+ * a string. Since 0.1.133 for booleans.
153
156
  */
154
157
  function coerceValue(raw, columnType) {
155
158
  if (columnType === "number") {
156
159
  const n = Number(raw);
157
160
  return Number.isNaN(n) ? raw : n;
158
161
  }
162
+ if (columnType === "boolean") {
163
+ const lower = raw.toLowerCase();
164
+ if (lower === "true") return true;
165
+ if (lower === "false") return false;
166
+ return raw;
167
+ }
159
168
  return raw;
160
169
  }
161
170
  /** Default condition type when no symbol matches the input. */
@@ -580,6 +589,10 @@ function derivePresetAspects(content) {
580
589
  * Convert in-memory dict-form snapshot to the wire form persisted on the
581
590
  * server. Entries-arrays are sorted by `field` so consumers (server-side
582
591
  * aspect derivation, dirty detection, equality checks) see a stable order.
592
+ *
593
+ * Empty aspects round-trip as empty (since 0.1.133): a saved view with no
594
+ * filters owns `filterOps` and clears them on apply, which is not the same
595
+ * thing as a view that never claimed the aspect.
583
596
  */
584
597
  function toWireSnapshot(snapshot) {
585
598
  const wire = {};
@@ -592,7 +605,7 @@ function toWireSnapshot(snapshot) {
592
605
  field,
593
606
  width: columnWidths[field]
594
607
  });
595
- if (entries.length > 0) columns.columnWidths = entries;
608
+ columns.columnWidths = entries;
596
609
  }
597
610
  wire.columns = columns;
598
611
  }
@@ -603,7 +616,7 @@ function toWireSnapshot(snapshot) {
603
616
  field,
604
617
  conditions: snapshot.filterOps[field]
605
618
  });
606
- if (entries.length > 0) wire.filterOps = entries;
619
+ wire.filterOps = entries;
607
620
  }
608
621
  if (snapshot.sorters) wire.sorters = snapshot.sorters;
609
622
  if (snapshot.itemsPerPage !== void 0) wire.itemsPerPage = snapshot.itemsPerPage;
@@ -614,7 +627,7 @@ function fromWireSnapshot(wire) {
614
627
  if (wire.columns) {
615
628
  const { columnNames, columnWidths } = wire.columns;
616
629
  const columns = { columnNames };
617
- if (columnWidths && columnWidths.length > 0) {
630
+ if (columnWidths) {
618
631
  const dict = {};
619
632
  for (const entry of columnWidths) dict[entry.field] = entry.width;
620
633
  columns.columnWidths = dict;
@@ -622,7 +635,7 @@ function fromWireSnapshot(wire) {
622
635
  snapshot.columns = columns;
623
636
  }
624
637
  if (wire.filters) snapshot.filters = wire.filters;
625
- if (wire.filterOps && wire.filterOps.length > 0) {
638
+ if (wire.filterOps) {
626
639
  const dict = {};
627
640
  for (const entry of wire.filterOps) dict[entry.field] = entry.conditions;
628
641
  snapshot.filterOps = dict;
@@ -819,7 +832,7 @@ function draftMatchesPreset(draft, presetSnapshot, availableAspects) {
819
832
  /**
820
833
  * Framework-agnostic wrapper over `@atscript/db-client`'s `Client` for the
821
834
  * `AsPresetEntry` table. Handles wire serialisation, list-splitting by
822
- * `type`, capabilities side-channel, and 401/403 → `denied` semantics.
835
+ * `type`, capabilities side-channel, and 401/403/404 → `denied` semantics.
823
836
  *
824
837
  * Stateless: every method is a fresh request. The Vue composable layer
825
838
  * holds reactive state; this class only translates intent → HTTP.
@@ -845,8 +858,8 @@ var PresetsClient = class {
845
858
  * `(app, tableKey)`. By default also fetches `capabilities` in parallel —
846
859
  * pass `{ capabilities: false }` for refresh-after-mutation calls (fav
847
860
  * toggle, default change, save/save-as, public toggle, rename, delete)
848
- * where role-derived capabilities can't have changed. Auth errors
849
- * (401/403) collapse to `denied: true` with empty data so the UI hides
861
+ * where role-derived capabilities can't have changed. Unavailable
862
+ * responses (401/403/404) collapse to `denied: true` with empty data so the UI hides
850
863
  * itself silently.
851
864
  */
852
865
  async list(opts = {}) {
@@ -858,7 +871,7 @@ var PresetsClient = class {
858
871
  };
859
872
  try {
860
873
  const [rows, capabilities] = await Promise.all([this.client.query({ filter }), fetchCapabilities ? this.loadCapabilities().catch((err) => {
861
- if (isAuthError(err)) throw err;
874
+ if (isUnavailableError(err)) throw err;
862
875
  return null;
863
876
  }) : Promise.resolve(void 0)]);
864
877
  const presets = [];
@@ -872,7 +885,7 @@ var PresetsClient = class {
872
885
  denied: false
873
886
  };
874
887
  } catch (err) {
875
- if (isAuthError(err)) return {
888
+ if (isUnavailableError(err)) return {
876
889
  presets: [],
877
890
  userConf: null,
878
891
  capabilities: null,
@@ -988,12 +1001,22 @@ var PresetsHttpError = class extends Error {
988
1001
  this.name = "PresetsHttpError";
989
1002
  }
990
1003
  };
991
- /** True for HTTP 401/403 across both `ClientError` and `PresetsHttpError`. */
992
- function isAuthError(err) {
993
- if (err instanceof _atscript_db_client.ClientError && (err.status === 401 || err.status === 403)) return true;
994
- if (err instanceof PresetsHttpError && (err.status === 401 || err.status === 403)) return true;
995
- return false;
1004
+ /**
1005
+ * True when the presets/app-prefs surface is simply not available to this
1006
+ * client: HTTP 401/403 (not signed in / not permitted) or 404 (the
1007
+ * controller is not mounted on this server). All three collapse to
1008
+ * `denied: true` — the UI hides itself silently instead of surfacing an
1009
+ * error. Covers both `ClientError` and `PresetsHttpError`.
1010
+ */
1011
+ function isUnavailableError(err) {
1012
+ const status = err instanceof _atscript_db_client.ClientError || err instanceof PresetsHttpError ? err.status : void 0;
1013
+ return status === 401 || status === 403 || status === 404;
996
1014
  }
1015
+ /**
1016
+ * @deprecated Renamed to `isUnavailableError` in 0.1.133 — it also covers
1017
+ * 404 (feature not mounted), not just auth failures.
1018
+ */
1019
+ const isAuthError = isUnavailableError;
997
1020
  //#endregion
998
1021
  //#region src/presets/app-prefs-client.ts
999
1022
  /**
@@ -1026,7 +1049,7 @@ var AppPrefsClient = class {
1026
1049
  denied: false
1027
1050
  };
1028
1051
  } catch (err) {
1029
- if (isAuthError(err)) return {
1052
+ if (isUnavailableError(err)) return {
1030
1053
  row: null,
1031
1054
  prefs: null,
1032
1055
  denied: true
@@ -1232,6 +1255,36 @@ function stateToUrlQueryString(state, defaults) {
1232
1255
  return (0, _uniqu_url_builder.buildUrl)(query);
1233
1256
  }
1234
1257
  /**
1258
+ * The `$`-controls {@link urlQueryStringToState} actually reads. Any other
1259
+ * `$key` is ignored by the parser, so it is NOT the table's to remove.
1260
+ */
1261
+ const CONSUMED_CONTROLS = new Set([
1262
+ "$sort",
1263
+ "$search",
1264
+ "$relevance",
1265
+ "$skip"
1266
+ ]);
1267
+ /** Characters that can only appear in a uniqu filter key, never in a page flag. */
1268
+ const FILTER_OPERATOR_CHAR = /[<>!~]/;
1269
+ /**
1270
+ * Whether {@link urlQueryStringToState} would consume the query key `key` —
1271
+ * i.e. whether the key belongs to the table rather than to the page hosting
1272
+ * it. This is the ownership rule the `useTableUrlQuery` router bridge reads
1273
+ * and writes by, so both directions agree: a key the parser ignores is never
1274
+ * removed, and a key it reads is the table's to remove.
1275
+ *
1276
+ * Shape-only, and deliberately conservative for bare `field=value` keys: a
1277
+ * filter on a column and a host flag are indistinguishable without the table
1278
+ * definition, so those are owned only once the bridge has written them
1279
+ * itself (or once a `prefix` namespaces them).
1280
+ *
1281
+ * @since 0.1.133
1282
+ */
1283
+ function urlQueryConsumesKey(key) {
1284
+ if (key.startsWith("$")) return CONSUMED_CONTROLS.has(key);
1285
+ return FILTER_OPERATOR_CHAR.test(key);
1286
+ }
1287
+ /**
1235
1288
  * Parse a URL query string back into the table state subset.
1236
1289
  *
1237
1290
  * Robust by design — schema drift and copy-paste errors must not break the
@@ -1628,10 +1681,230 @@ function reorderColumnNames(names, fromPath, toPath, position) {
1628
1681
  return without.slice(0, insertAt).concat(fromPath, without.slice(insertAt));
1629
1682
  }
1630
1683
  //#endregion
1684
+ //#region src/export/csv.ts
1685
+ const FORMULA_LEADERS = new Set([
1686
+ "=",
1687
+ "+",
1688
+ "-",
1689
+ "@",
1690
+ " ",
1691
+ "\r"
1692
+ ]);
1693
+ /** RFC 4180 row separator. */
1694
+ const CRLF = "\r\n";
1695
+ /**
1696
+ * Render one value as an RFC 4180 field: formula-escaped (unless opted out),
1697
+ * then quoted when it contains the delimiter, a quote, CR or LF — with inner
1698
+ * quotes doubled.
1699
+ */
1700
+ function csvCell(value, opts = {}) {
1701
+ const delimiter = opts.delimiter ?? ",";
1702
+ let text = value === null || value === void 0 ? "" : String(value);
1703
+ if (typeof value === "string" && opts.escapeFormulas !== false && text.length > 0 && FORMULA_LEADERS.has(text[0])) text = `'${text}`;
1704
+ return text.includes(delimiter) || text.includes("\"") || text.includes("\n") || text.includes("\r") ? `"${text.replaceAll("\"", "\"\"")}"` : text;
1705
+ }
1706
+ /**
1707
+ * Serialise a header row + data rows to an RFC 4180 CSV string (`\r\n` row
1708
+ * separators, quoted fields where required, optional UTF-8 BOM).
1709
+ *
1710
+ * Pure — no DOM, no framework. See {@link csvCell} for the per-field rules.
1711
+ */
1712
+ function toCsv(header, rows, opts = {}) {
1713
+ const delimiter = opts.delimiter ?? ",";
1714
+ const lines = [header.map((h) => csvCell(h, opts)).join(delimiter)];
1715
+ for (const row of rows) lines.push(row.map((v) => csvCell(v, opts)).join(delimiter));
1716
+ const body = lines.join(CRLF) + CRLF;
1717
+ return opts.bom ? `${body}` : body;
1718
+ }
1719
+ //#endregion
1720
+ //#region src/export/export-paging.ts
1721
+ /** Default page size used by the export pager when the caller gives none. */
1722
+ const DEFAULT_EXPORT_PAGE_SIZE = 500;
1723
+ /** Rejection raised when an export is cancelled through its `AbortSignal`. */
1724
+ var ExportAbortError = class extends Error {
1725
+ /** Matches the DOM convention so `err.name === "AbortError"` checks work. */
1726
+ name = "AbortError";
1727
+ constructor(message = "Export aborted") {
1728
+ super(message);
1729
+ }
1730
+ };
1731
+ /**
1732
+ * Append the primary key(s) to a query's `$sort` as a tiebreaker so paging is
1733
+ * stable: two rows equal on every user sorter keep a deterministic relative
1734
+ * order, and no row is skipped or duplicated across page boundaries.
1735
+ *
1736
+ * Existing sort fields are preserved in order and never replaced — a pk the
1737
+ * user already sorts by is left where it is. Returns the input query when
1738
+ * there is nothing to add.
1739
+ */
1740
+ function withStableOrder(query, primaryKeys) {
1741
+ if (primaryKeys.length === 0) return query;
1742
+ const current = query.controls?.$sort ?? {};
1743
+ const missing = primaryKeys.filter((pk) => !(pk in current));
1744
+ if (missing.length === 0) return query;
1745
+ const $sort = { ...current };
1746
+ for (const pk of missing) $sort[pk] = 1;
1747
+ return {
1748
+ ...query,
1749
+ controls: {
1750
+ ...query.controls,
1751
+ $sort
1752
+ }
1753
+ };
1754
+ }
1755
+ /**
1756
+ * Page through `query` until the endpoint runs out of rows, collecting every
1757
+ * row into one array.
1758
+ *
1759
+ * Stops when a page comes back short (fewer rows than `pageSize`), when the
1760
+ * reported `count` is reached, or when `maxRows` is hit. Checks `signal`
1761
+ * before and after every request so a cancel lands between pages rather than
1762
+ * mid-flight.
1763
+ */
1764
+ async function collectExportRows(opts) {
1765
+ const pageSize = opts.pageSize && opts.pageSize > 0 ? opts.pageSize : 500;
1766
+ const mapRow = opts.mapRow ?? ((row) => row);
1767
+ const rows = [];
1768
+ let total;
1769
+ let page = 1;
1770
+ for (;;) {
1771
+ throwIfAborted(opts.signal);
1772
+ const result = await opts.fetchPage(opts.query, page, pageSize);
1773
+ throwIfAborted(opts.signal);
1774
+ if (page === 1 && typeof result.count === "number") total = result.count;
1775
+ const data = result.data ?? [];
1776
+ const take = opts.maxRows === void 0 ? data.length : Math.min(data.length, opts.maxRows - rows.length);
1777
+ for (let i = 0; i < take; i++) rows.push(mapRow(data[i]));
1778
+ opts.onProgress?.(rows.length, total);
1779
+ if (data.length < pageSize || opts.maxRows !== void 0 && rows.length >= opts.maxRows || total !== void 0 && rows.length >= total) break;
1780
+ page++;
1781
+ }
1782
+ return {
1783
+ rows,
1784
+ total
1785
+ };
1786
+ }
1787
+ function throwIfAborted(signal) {
1788
+ if (signal?.aborted) throw new ExportAbortError();
1789
+ }
1790
+ //#endregion
1791
+ //#region src/export/export-value.ts
1792
+ /**
1793
+ * Turn a raw cell value into the scalar an export carries, using the same
1794
+ * column metadata the cell renderers read:
1795
+ *
1796
+ * - `null` / `undefined` → `null` (an empty CSV field).
1797
+ * - union / enum columns (`column.options`) → the option's **label**, so the
1798
+ * file reads like the screen; an unknown key falls back to the raw value.
1799
+ * - `Date` → ISO 8601 (spreadsheet- and re-import-friendly, unlike a
1800
+ * locale-formatted date).
1801
+ * - arrays → comma-joined scalars; other objects → JSON.
1802
+ * - numbers / booleans pass through untouched so a spreadsheet keeps them
1803
+ * numeric.
1804
+ *
1805
+ * Callers override per column (`formatters`) or globally (`formatCell`) when
1806
+ * they want different text.
1807
+ */
1808
+ function resolveExportValue(value, column) {
1809
+ if (value === null || value === void 0) return null;
1810
+ const options = column?.options;
1811
+ if (options?.length) {
1812
+ const key = typeof value === "string" || typeof value === "number" ? String(value) : void 0;
1813
+ if (key !== void 0) {
1814
+ const hit = options.find((o) => o.key === key);
1815
+ if (hit) return hit.label;
1816
+ }
1817
+ }
1818
+ if (typeof value === "number" || typeof value === "boolean") return value;
1819
+ if (typeof value === "string") return value;
1820
+ if (value instanceof Date) return value.toISOString();
1821
+ if (Array.isArray(value)) return value.map((v) => scalarText(v)).join(", ");
1822
+ return scalarText(value);
1823
+ }
1824
+ function scalarText(value) {
1825
+ if (value === null || value === void 0 || typeof value === "function") return "";
1826
+ try {
1827
+ return (0, _atscript_ui.str)(value);
1828
+ } catch {
1829
+ return "";
1830
+ }
1831
+ }
1832
+ //#endregion
1833
+ //#region src/columns/display-columns.ts
1834
+ /** Columns with no explicit `order` land after every server column. */
1835
+ const TRAILING_ORDER = Number.MAX_SAFE_INTEGER;
1836
+ /** Turn a {@link DisplayColumnDef} into the `ColumnDef` the table renders. */
1837
+ function displayColumnToDef(def) {
1838
+ return {
1839
+ path: def.key,
1840
+ label: def.label,
1841
+ type: def.type ?? "text",
1842
+ component: def.component,
1843
+ sortable: def.sortable === "local",
1844
+ filterable: false,
1845
+ nullable: true,
1846
+ order: def.order ?? TRAILING_ORDER,
1847
+ width: def.width,
1848
+ local: true
1849
+ };
1850
+ }
1851
+ /**
1852
+ * Merge client-owned columns into the server's column list, ordered by each
1853
+ * column's `order`. `Array#sort` is stable, so server columns that share an
1854
+ * `order` keep their `/meta` sequence. Returns `base` untouched when there is
1855
+ * nothing to merge.
1856
+ */
1857
+ function mergeDisplayColumns(base, display) {
1858
+ if (display.length === 0) return base;
1859
+ return [...base, ...display.map(displayColumnToDef)].toSorted((a, b) => a.order - b.order);
1860
+ }
1861
+ //#endregion
1862
+ //#region src/utils/sort-rows.ts
1863
+ /**
1864
+ * Coerce a primitive cell value to the text used for comparing and matching
1865
+ * cells — the string compare in {@link sortRowsLocally} and the substring
1866
+ * search of an in-memory table. Objects and functions collapse to `""` so
1867
+ * `'[object Object]'` never drives an ordering or matches a search.
1868
+ */
1869
+ function cellAsString(v) {
1870
+ if (v == null) return "";
1871
+ if (typeof v === "string") return v;
1872
+ if (typeof v === "number" || typeof v === "boolean") return v.toString();
1873
+ return "";
1874
+ }
1875
+ /**
1876
+ * Sort rows in memory by `sorters`, numerically when both sides are numbers
1877
+ * and locale-aware otherwise. Returns a NEW array; the input is untouched. A
1878
+ * no-op (returns the input reference) when there is nothing to sort.
1879
+ *
1880
+ * `getValue` reads a sorter's field off a row — pass a dot-path reader for
1881
+ * nested fields; the default is a flat property read.
1882
+ */
1883
+ function sortRowsLocally(rows, sorters, getValue = (row, field) => row[field]) {
1884
+ if (sorters.length === 0 || rows.length < 2) return rows;
1885
+ return rows.toSorted((a, b) => {
1886
+ for (const s of sorters) {
1887
+ const dir = s.direction === "desc" ? -1 : 1;
1888
+ const av = getValue(a, s.field);
1889
+ const bv = getValue(b, s.field);
1890
+ if (typeof av === "number" && typeof bv === "number") {
1891
+ if (av < bv) return -dir;
1892
+ if (av > bv) return dir;
1893
+ } else {
1894
+ const cmp = cellAsString(av).localeCompare(cellAsString(bv));
1895
+ if (cmp !== 0) return cmp * dir;
1896
+ }
1897
+ }
1898
+ return 0;
1899
+ });
1900
+ }
1901
+ //#endregion
1631
1902
  exports.APP_CONF_PREFIX = APP_CONF_PREFIX;
1632
1903
  exports.AppPrefsClient = AppPrefsClient;
1904
+ exports.DEFAULT_EXPORT_PAGE_SIZE = DEFAULT_EXPORT_PAGE_SIZE;
1633
1905
  exports.DEFAULT_ROW_HEIGHT_PX = DEFAULT_ROW_HEIGHT_PX;
1634
1906
  exports.DRAFT_PERSISTED_ASPECTS = DRAFT_PERSISTED_ASPECTS;
1907
+ exports.ExportAbortError = ExportAbortError;
1635
1908
  exports.MAX_DEFAULT_COLUMN_WIDTH_PX = MAX_DEFAULT_COLUMN_WIDTH_PX;
1636
1909
  exports.NULL_OPS = NULL_OPS;
1637
1910
  exports.PRESET_ASPECTS = PRESET_ASPECTS;
@@ -1645,11 +1918,14 @@ exports.appConfId = appConfId;
1645
1918
  exports.arraysEqual = arraysEqual;
1646
1919
  exports.blockStartFor = blockStartFor;
1647
1920
  exports.buildTableQuery = buildTableQuery;
1921
+ exports.cellAsString = cellAsString;
1648
1922
  exports.clampTopIndex = clampTopIndex;
1923
+ exports.collectExportRows = collectExportRows;
1649
1924
  exports.columnFilterType = columnFilterType;
1650
1925
  exports.computeDefaultColumnWidth = computeDefaultColumnWidth;
1651
1926
  exports.conditionLabel = conditionLabel;
1652
1927
  exports.conditionsForType = conditionsForType;
1928
+ exports.csvCell = csvCell;
1653
1929
  exports.dateShortcuts = dateShortcuts;
1654
1930
  exports.debounce = debounce;
1655
1931
  exports.defaultCondition = defaultCondition;
@@ -1669,6 +1945,8 @@ exports.isEmptyDraft = isEmptyDraft;
1669
1945
  exports.isFilled = isFilled;
1670
1946
  exports.isSimpleEq = isSimpleEq;
1671
1947
  exports.isSystemPresetId = isSystemPresetId;
1948
+ exports.isUnavailableError = isUnavailableError;
1949
+ exports.mergeDisplayColumns = mergeDisplayColumns;
1672
1950
  exports.mergeFilters = mergeFilters;
1673
1951
  exports.mergeSorters = mergeSorters;
1674
1952
  exports.normaliseSystemPresetId = normaliseSystemPresetId;
@@ -1678,20 +1956,25 @@ exports.planFetch = planFetch;
1678
1956
  exports.reconcileColumnWidthDefaults = reconcileColumnWidthDefaults;
1679
1957
  exports.reorderColumnNames = reorderColumnNames;
1680
1958
  exports.resolveAspectGate = resolveAspectGate;
1959
+ exports.resolveExportValue = resolveExportValue;
1681
1960
  exports.resolveSystemPresets = resolveSystemPresets;
1682
1961
  exports.rowsToPks = rowsToPks;
1683
1962
  exports.sameColumnSet = sameColumnSet;
1684
1963
  exports.serializeDraft = serializeDraft;
1685
1964
  exports.setsEqual = setsEqual;
1965
+ exports.sortRowsLocally = sortRowsLocally;
1686
1966
  exports.sortersEqual = sortersEqual;
1687
1967
  exports.stableStringify = stableStringify;
1688
1968
  exports.stateToUrlQueryString = stateToUrlQueryString;
1969
+ exports.toCsv = toCsv;
1689
1970
  exports.toWireSnapshot = toWireSnapshot;
1690
1971
  exports.togglePk = togglePk;
1691
1972
  exports.trimSelection = trimSelection;
1692
1973
  exports.unescapeRegex = unescapeRegex;
1693
1974
  exports.uniqueryFilterToFieldFilters = uniqueryFilterToFieldFilters;
1975
+ exports.urlQueryConsumesKey = urlQueryConsumesKey;
1694
1976
  exports.urlQueryStringToState = urlQueryStringToState;
1695
1977
  exports.userConfId = userConfId;
1696
1978
  exports.walkBackwardAbsorb = walkBackwardAbsorb;
1697
1979
  exports.walkForwardAbsorb = walkForwardAbsorb;
1980
+ exports.withStableOrder = withStableOrder;