@atscript/ui 0.1.103 → 0.1.104

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
@@ -30,7 +30,7 @@ const UI_FORM_SUFFIX_ICON = "ui.form.suffix.icon";
30
30
  const UI_TABLE_WIDTH = "ui.table.width";
31
31
  const UI_TABLE_COMPONENT = "ui.table.component";
32
32
  const UI_TABLE_SELECT_WITH = "ui.table.selectWith";
33
- const UI_TABLE_HIDDEN = "ui.table.hidden";
33
+ const UI_TABLE_EXCLUDE = "ui.table.exclude";
34
34
  const UI_TABLE_ATTR = "ui.table.attr";
35
35
  const UI_TABLE_CLASSES = "ui.table.classes";
36
36
  const UI_TABLE_STYLES = "ui.table.styles";
@@ -811,6 +811,35 @@ function setByPath(obj, path, value) {
811
811
  }
812
812
  current[last] = value;
813
813
  }
814
+ /**
815
+ * Deletes the own key at a dot-separated path (form-data wrapper aware — derefs
816
+ * `obj.value` first). Walks to the parent WITHOUT vivifying intermediate nodes:
817
+ * if any ancestor is missing, the call is a no-op (nothing to delete).
818
+ *
819
+ * Unlike `setByPath(obj, path, undefined)`, this leaves NO own key behind — the
820
+ * leaf reads as absent (`'k' in parent === false`), which keeps `deepEqual`
821
+ * structural comparisons in sync (a present `undefined` own-key and an absent
822
+ * key are NOT structurally equal under the own-key walk). Used by
823
+ * {@link applyFormChanges} to apply a clear-to-`undefined` change as a delete.
824
+ *
825
+ * Empty path clears the root domain value (`obj.value = undefined`).
826
+ */
827
+ function deleteByPath(obj, path) {
828
+ if (!path) {
829
+ obj.value = void 0;
830
+ return;
831
+ }
832
+ const keys = path.split(".");
833
+ const last = keys.pop();
834
+ if (last === void 0) return;
835
+ let current = obj.value;
836
+ for (const key of keys) {
837
+ if (current === null || current === void 0 || typeof current !== "object") return;
838
+ current = current[key];
839
+ }
840
+ if (current === null || current === void 0 || typeof current !== "object") return;
841
+ delete current[last];
842
+ }
814
843
  function parseStaticDefault(raw, prop) {
815
844
  if (typeof raw !== "string") return raw;
816
845
  if (prop.type.kind === "" && prop.type.designType === "string") return raw;
@@ -884,8 +913,11 @@ function detectUnionVariant(value, variants) {
884
913
  const disc = getVariantsDiscriminator(variants);
885
914
  if (disc && value !== null && typeof value === "object") {
886
915
  const tag = value[disc.propertyName];
887
- const idx = disc.indexMapping[String(tag)];
888
- if (idx !== void 0) return idx;
916
+ const key = String(tag);
917
+ if (Object.prototype.hasOwnProperty.call(disc.indexMapping, key)) {
918
+ const idx = disc.indexMapping[key];
919
+ if (idx !== void 0) return idx;
920
+ }
889
921
  }
890
922
  for (let i = 0; i < variants.length; i++) try {
891
923
  if (getVariantValidator(variants[i]).validate(value, true)) return i;
@@ -893,6 +925,35 @@ function detectUnionVariant(value, variants) {
893
925
  return 0;
894
926
  }
895
927
  //#endregion
928
+ //#region src/form/clone.ts
929
+ /**
930
+ * Structural deep clone of plain JSON-ish data (objects / arrays / primitives /
931
+ * `Date`). Walks OWN-ENUMERABLE keys only (matches the own-key discipline in
932
+ * `diff.ts` — never copies an accidental prototype) and copies leaves by value.
933
+ *
934
+ * `structuredClone` is deliberately NOT used: it throws on functions and on Vue
935
+ * reactive proxies. The optional `unwrap` hook lets a framework caller
936
+ * de-proxy each value first (vue-form passes `toRaw`); the core omits it.
937
+ *
938
+ * The SINGLE deep-clone primitive for the form engine — used by
939
+ * `applyFormChanges`, `buildFormRebase`, and vue-form's baseline snapshot. Do
940
+ * not reimplement structural cloning elsewhere.
941
+ */
942
+ function deepClone(value, unwrap) {
943
+ const v = unwrap ? unwrap(value) : value;
944
+ if (v === null || typeof v !== "object") return v;
945
+ if (v instanceof Date) return new Date(v.getTime());
946
+ if (Array.isArray(v)) {
947
+ const out = [];
948
+ for (let i = 0; i < v.length; i++) out.push(deepClone(v[i], unwrap));
949
+ return out;
950
+ }
951
+ const src = v;
952
+ const out = {};
953
+ for (const k of Object.keys(src)) out[k] = deepClone(src[k], unwrap);
954
+ return out;
955
+ }
956
+ //#endregion
896
957
  //#region src/form/validate.ts
897
958
  let defaultValidatorPlugins = [];
898
959
  /** Replace the default validator plugins applied to every form/field validator. */
@@ -1351,6 +1412,10 @@ function stableKey(v) {
1351
1412
  * Structural deep equality (order-sensitive for arrays). `NaN` equals `NaN`
1352
1413
  * (revert-aware for NaN scalars) while `0` / `-0` stay equal (matches DB
1353
1414
  * intent — `===` treats them equal, only NaN is special-cased).
1415
+ *
1416
+ * The single comparator shared across the form engine: diff, conflict
1417
+ * detection ({@link buildFormRebase}), and apply all route through this — never
1418
+ * reimplement equality elsewhere.
1354
1419
  */
1355
1420
  function deepEqual(a, b) {
1356
1421
  if (a === b) return true;
@@ -1377,6 +1442,228 @@ function deepEqual(a, b) {
1377
1442
  return true;
1378
1443
  }
1379
1444
  //#endregion
1445
+ //#region src/form/dirty.ts
1446
+ /**
1447
+ * True when the field at dot-path `path` is dirty given a {@link FormFieldChange}
1448
+ * list (as produced by {@link buildFormDiff}).
1449
+ *
1450
+ * The change list is leaf-grained for scalars/objects but WHOLE-ARRAY for arrays,
1451
+ * so a field at `path` is dirty iff some change path equals `path` OR starts with
1452
+ * `path + "."`:
1453
+ *
1454
+ * - scalar / leaf field (incl. nested `address.city`) → exact match.
1455
+ * - object / section container → no entry at its own path, only its leaves →
1456
+ * matched by the PREFIX branch.
1457
+ * - whole-array field → one entry at the array root → exact match.
1458
+ * - a field rendered for an array-ITEM leaf (e.g. `items.0.qty`) → NOT detectable:
1459
+ * the array diff emits a single whole-array change at the array root, never
1460
+ * per-item leaf paths, so this correctly returns false (the array container
1461
+ * lights up instead). This is a known, documented limitation.
1462
+ *
1463
+ * The prefix uses `path + "."` so field `item` never matches a change at `items`
1464
+ * (no false positives).
1465
+ *
1466
+ * Empty `path` `''` is the wrapped form root — every change is nested under it,
1467
+ * so it is considered dirty iff there are ANY changes.
1468
+ */
1469
+ function isPathDirty(changes, path) {
1470
+ if (path === "") return changes.length > 0;
1471
+ const prefix = `${path}.`;
1472
+ for (const change of changes) if (change.path === path || change.path.startsWith(prefix)) return true;
1473
+ return false;
1474
+ }
1475
+ /**
1476
+ * Precomputes the set of ALL dirty paths from a {@link FormFieldChange} list so
1477
+ * that membership is an O(1) `Set.has(path)` instead of {@link isPathDirty}'s
1478
+ * per-call O(changes) prefix scan. Callers that probe many fields against the
1479
+ * same change list (e.g. a form rendering one field per leaf) build this once
1480
+ * and query it per field.
1481
+ *
1482
+ * For each change path `C` it adds `C` AND every dot-prefix ancestor of `C`
1483
+ * (so `'address.city'` adds both `'address.city'` and `'address'`), matching
1484
+ * `isPathDirty`'s "exact OR `path + '.'` prefix" predicate — an ancestor
1485
+ * container is dirty exactly when some change is nested under it. The wrapped
1486
+ * root `''` is added iff there are ANY changes, mirroring `isPathDirty('')`.
1487
+ *
1488
+ * INVARIANT (locked, tested): for EVERY path `P`,
1489
+ * `collectDirtyPaths(changes).has(P) === isPathDirty(changes, P)`. This is a
1490
+ * precompute of the SAME predicate, not a second one — keep them in lockstep.
1491
+ */
1492
+ function collectDirtyPaths(changes) {
1493
+ const dirty = /* @__PURE__ */ new Set();
1494
+ if (changes.length === 0) return dirty;
1495
+ dirty.add("");
1496
+ for (const change of changes) {
1497
+ const path = change.path;
1498
+ dirty.add(path);
1499
+ let dot = path.indexOf(".");
1500
+ while (dot !== -1) {
1501
+ dirty.add(path.slice(0, dot));
1502
+ dot = path.indexOf(".", dot + 1);
1503
+ }
1504
+ }
1505
+ return dirty;
1506
+ }
1507
+ //#endregion
1508
+ //#region src/form/apply.ts
1509
+ /**
1510
+ * Applies a {@link FormFieldChange} list onto a WRAPPED form-data container
1511
+ * (`{ value: domainData }`), mutating it in place and returning the same
1512
+ * reference. The inverse direction of {@link buildFormDiff}: where the diff
1513
+ * READS `(baseline, current)` into changes, this WRITES changes onto data.
1514
+ *
1515
+ * IMPORTANT: pass a CLONE, never the live fetched row — every write mutates
1516
+ * `data` directly. Callers that need the original intact should
1517
+ * `deepClone(data)` first (see {@link deepClone}).
1518
+ *
1519
+ * Per-change semantics (the single place the apply rules live, so
1520
+ * {@link buildFormRebase} stays consistent):
1521
+ *
1522
+ * - `kind: 'set'`:
1523
+ * - `change.after === undefined` → DELETE the own key at `change.path` (walk
1524
+ * to parent, `delete`). A cleared field must read as ABSENT, not as a
1525
+ * present `undefined` own-key — otherwise a re-diff sees a structural
1526
+ * mismatch where the form intends "no value". `setByPath(…, undefined)`
1527
+ * leaves an own key behind, so we use {@link deleteByPath} instead.
1528
+ * - otherwise → `setByPath(data, change.path, change.after)`.
1529
+ * - `kind: 'array'`: whole-array set via `setByPath(data, change.path,
1530
+ * change.after)` (LOCKED Option A — no per-element merge; the diff already
1531
+ * carried the full after-array).
1532
+ *
1533
+ * The `def` is currently unused by the apply walk (paths fully describe the
1534
+ * write target) but is part of the signature for parity with
1535
+ * `buildFormDiff`/`buildFormRebase`, so the rebase engine threads one `def`
1536
+ * uniformly through diff + apply.
1537
+ */
1538
+ function applyFormChanges(_def, data, changes) {
1539
+ for (const change of changes) if (change.kind === "set" && change.after === void 0) deleteByPath(data, change.path);
1540
+ else setByPath(data, change.path, change.after);
1541
+ return data;
1542
+ }
1543
+ //#endregion
1544
+ //#region src/form/rebase.ts
1545
+ /**
1546
+ * Pure 3-way rebase for a change-tracked form. Given the current baseline `B0`,
1547
+ * the live form `C`, and a fresh upstream `U`, produces the form rewritten as
1548
+ * `U` + the local diff (`C` vs `B0`) reapplied on top:
1549
+ *
1550
+ * - Fields the user never touched adopt upstream's value.
1551
+ * - Local edits survive (reapplied onto the upstream clone).
1552
+ * - Fields changed on BOTH sides to different values are conflicts, resolved by
1553
+ * `opts.conflict` (`'ours'` keeps local, `'theirs'` takes upstream).
1554
+ *
1555
+ * All inputs are WRAPPED form-data containers (`{ value: domainData }`). The
1556
+ * result `next` is a fresh container; no input is mutated.
1557
+ *
1558
+ * `diffOptions` are forwarded to BOTH internal `buildFormDiff` passes so the
1559
+ * same field exclusions apply (notably the `@db.column.version` column and the
1560
+ * `$cas` policy) on the local and upstream sides — keep them identical to the
1561
+ * options the caller uses for its own change tracking.
1562
+ */
1563
+ function buildFormRebase(def, baseline, current, upstream, opts, diffOptions) {
1564
+ const conflictMode = opts?.conflict ?? "ours";
1565
+ const local = buildFormDiff(def, baseline, current, diffOptions).changes;
1566
+ const upstreamChanges = buildFormDiff(def, baseline, upstream, diffOptions).changes;
1567
+ const upstreamByPath = /* @__PURE__ */ new Map();
1568
+ for (const uc of upstreamChanges) upstreamByPath.set(uc.path, uc);
1569
+ const next = deepClone(upstream);
1570
+ const conflicts = [];
1571
+ for (const lc of local) {
1572
+ const clearedAncestor = findClearedAncestor(lc.path, baseline, upstream);
1573
+ if (clearedAncestor !== void 0) {
1574
+ conflicts.push(clearedAncestor);
1575
+ if (conflictMode === "ours") setByPath(next, clearedAncestor, deepClone(getByPath(current, clearedAncestor)));
1576
+ continue;
1577
+ }
1578
+ const uc = upstreamByPath.get(lc.path);
1579
+ if (uc !== void 0) {
1580
+ if (deepEqual(lc.after, uc.after)) continue;
1581
+ conflicts.push(lc.path);
1582
+ if (conflictMode === "ours") reapply(def, next, lc);
1583
+ continue;
1584
+ }
1585
+ reapply(def, next, lc);
1586
+ }
1587
+ const reapplied = buildFormDiff(def, upstream, deepClone(next), diffOptions).changes;
1588
+ return {
1589
+ next,
1590
+ conflicts: [...new Set(conflicts)],
1591
+ reapplied
1592
+ };
1593
+ }
1594
+ /**
1595
+ * Reapplies a single local change onto `next`, DEEP-CLONING its `after` value
1596
+ * first. `lc.after` is a LIVE reference into `current` (buildFormDiff holds live
1597
+ * refs), so for a `kind:'array'` or whole-object/union `set` change a raw apply
1598
+ * would make `next.value`'s node `===` `current.value`'s node — violating the
1599
+ * `FormRebaseResult.next` contract ("never aliases any input container"). The
1600
+ * ancestor-clear branch already deep-clones before writing; this keeps the two
1601
+ * leaf-reapply sites consistent.
1602
+ */
1603
+ function reapply(def, next, lc) {
1604
+ applyFormChanges(def, next, [{
1605
+ ...lc,
1606
+ after: deepClone(lc.after)
1607
+ }]);
1608
+ }
1609
+ /**
1610
+ * Returns the SHALLOWEST strict ancestor of `leafPath` that was an object/array
1611
+ * in `baseline` but is `null`/`undefined` in `upstream` (upstream cleared the
1612
+ * subtree), or `undefined` when no ancestor was cleared. The leaf path itself is
1613
+ * never considered an ancestor.
1614
+ */
1615
+ function findClearedAncestor(leafPath, baseline, upstream) {
1616
+ if (!leafPath.includes(".")) return void 0;
1617
+ const segs = leafPath.split(".");
1618
+ let acc = "";
1619
+ for (let i = 0; i < segs.length - 1; i++) {
1620
+ acc = acc ? `${acc}.${segs[i]}` : segs[i];
1621
+ const base = getByPath(baseline, acc);
1622
+ if (typeof base !== "object" || base === null) continue;
1623
+ const up = getByPath(upstream, acc);
1624
+ if (up === null || up === void 0) return acc;
1625
+ }
1626
+ }
1627
+ //#endregion
1628
+ //#region src/form/union-detect.ts
1629
+ /**
1630
+ * True when ANY union field in the form resolves to a DIFFERENT discriminated
1631
+ * variant between two wrapped data containers. A variant picker typically
1632
+ * detects its variant index once at setup and keys the variant subtree on it,
1633
+ * so a rebase that lands a different variant (via conflict OR an upstream-only
1634
+ * switch) needs a remount to re-detect. This walks union + nested-object fields
1635
+ * and compares `detectUnionVariant` at each union path.
1636
+ *
1637
+ * Scope note (pragmatic): walks standalone + nested-OBJECT union fields. Unions
1638
+ * nested INSIDE array items are not walked — an array renderer that keeps a
1639
+ * stable per-item key across in-place value mutations would not remount an
1640
+ * existing row's picker on an upstream-driven variant flip, but that collision
1641
+ * (a 3-way rebase landing a different union variant inside an unchanged array
1642
+ * row) is a rare edge. TODO: extend to array-item unions if a real consumer
1643
+ * hits a stuck picker inside an array row.
1644
+ */
1645
+ function unionVariantChanged(def, before, after) {
1646
+ return walkUnionFields(def.fields, "", before, after);
1647
+ }
1648
+ function walkUnionFields(fields, prefix, before, after) {
1649
+ for (const field of fields) {
1650
+ if (field.phantom) continue;
1651
+ const fullPath = field.path ? prefix ? `${prefix}.${field.path}` : field.path : prefix;
1652
+ if (isUnionField(field)) {
1653
+ const variants = field.unionVariants;
1654
+ if (variants.length > 1) {
1655
+ if (detectUnionVariant(getByPath(before, fullPath), variants) !== detectUnionVariant(getByPath(after, fullPath), variants)) return true;
1656
+ }
1657
+ continue;
1658
+ }
1659
+ if (isObjectField(field)) {
1660
+ const objectDef = field.objectDef;
1661
+ if (walkUnionFields(objectDef.fields, fullPath, before, after)) return true;
1662
+ }
1663
+ }
1664
+ return false;
1665
+ }
1666
+ //#endregion
1380
1667
  //#region src/form/error-utils.ts
1381
1668
  /**
1382
1669
  * Framework-agnostic helpers for working with form-error maps keyed by
@@ -1861,6 +2148,7 @@ function createTableDef(meta, preDeserializedType) {
1861
2148
  const kind = prop.type.kind;
1862
2149
  if (path.includes(".") || kind === "object" || kind === "array") continue;
1863
2150
  }
2151
+ if (getFieldMeta(prop, "ui.table.exclude") !== void 0) continue;
1864
2152
  const fieldMeta = meta.fields[path];
1865
2153
  const options = extractLiteralOptions(prop);
1866
2154
  const valueHelpInfo = extractValueHelp(prop);
@@ -1877,7 +2165,6 @@ function createTableDef(meta, preDeserializedType) {
1877
2165
  sortable: fieldMeta?.sortable ?? false,
1878
2166
  filterable: fieldMeta?.filterable ?? false,
1879
2167
  nullable: prop.optional === true,
1880
- visible: getFieldMeta(prop, UI_TABLE_HIDDEN) === void 0,
1881
2168
  width: getFieldMeta(prop, UI_TABLE_WIDTH),
1882
2169
  maxLen: maxLengthMeta?.length,
1883
2170
  order: getFieldMeta(prop, "ui.table.order") ?? Infinity,
@@ -1893,6 +2180,7 @@ function createTableDef(meta, preDeserializedType) {
1893
2180
  type,
1894
2181
  columns,
1895
2182
  flatMap,
2183
+ fetchableFields: new Set(Object.keys(meta.fields)),
1896
2184
  primaryKeys: meta.primaryKeys,
1897
2185
  preferredId: meta.preferredId ?? meta.primaryKeys,
1898
2186
  versionColumn: meta.versionColumn,
@@ -1966,10 +2254,6 @@ function str(value) {
1966
2254
  }
1967
2255
  //#endregion
1968
2256
  //#region src/table/column-resolver.ts
1969
- /** Get visible columns only, already sorted by order. */
1970
- function getVisibleColumns(def) {
1971
- return def.columns.filter((c) => c.visible);
1972
- }
1973
2257
  /** Get sortable columns. */
1974
2258
  function getSortableColumns(def) {
1975
2259
  return def.columns.filter((c) => c.sortable);
@@ -2050,11 +2334,11 @@ exports.UI_FORM_VALIDATE = UI_FORM_VALIDATE;
2050
2334
  exports.UI_TABLE_ATTR = UI_TABLE_ATTR;
2051
2335
  exports.UI_TABLE_CLASSES = UI_TABLE_CLASSES;
2052
2336
  exports.UI_TABLE_COMPONENT = UI_TABLE_COMPONENT;
2337
+ exports.UI_TABLE_EXCLUDE = UI_TABLE_EXCLUDE;
2053
2338
  exports.UI_TABLE_FN_ATTR = UI_TABLE_FN_ATTR;
2054
2339
  exports.UI_TABLE_FN_CLASSES = UI_TABLE_FN_CLASSES;
2055
2340
  exports.UI_TABLE_FN_PREFIX = UI_TABLE_FN_PREFIX;
2056
2341
  exports.UI_TABLE_FN_STYLES = UI_TABLE_FN_STYLES;
2057
- exports.UI_TABLE_HIDDEN = UI_TABLE_HIDDEN;
2058
2342
  exports.UI_TABLE_ORDER = UI_TABLE_ORDER;
2059
2343
  exports.UI_TABLE_SELECT_WITH = UI_TABLE_SELECT_WITH;
2060
2344
  exports.UI_TABLE_STYLES = UI_TABLE_STYLES;
@@ -2063,17 +2347,23 @@ exports.UI_TABLE_WIDTH = UI_TABLE_WIDTH;
2063
2347
  exports.UI_TYPE = UI_TYPE;
2064
2348
  exports.ValueHelpClient = ValueHelpClient;
2065
2349
  exports.WF_ACTION_WITH_DATA = WF_ACTION_WITH_DATA;
2350
+ exports.applyFormChanges = applyFormChanges;
2066
2351
  exports.asArray = asArray;
2067
2352
  exports.buildDescendantErrorCounts = buildDescendantErrorCounts;
2068
2353
  exports.buildFormDiff = buildFormDiff;
2354
+ exports.buildFormRebase = buildFormRebase;
2069
2355
  exports.buildGridClasses = buildGridClasses;
2070
2356
  exports.buildUnionVariants = buildUnionVariants;
2357
+ exports.collectDirtyPaths = collectDirtyPaths;
2071
2358
  exports.createFieldValidator = createFieldValidator;
2072
2359
  exports.createFormData = createFormData;
2073
2360
  exports.createFormDef = createFormDef;
2074
2361
  exports.createFormValueResolver = createFormValueResolver;
2075
2362
  exports.createTableDef = createTableDef;
2363
+ exports.deepClone = deepClone;
2364
+ exports.deepEqual = deepEqual;
2076
2365
  exports.defaultResolver = defaultResolver;
2366
+ exports.deleteByPath = deleteByPath;
2077
2367
  exports.detectUnionVariant = detectUnionVariant;
2078
2368
  exports.enforceScale = enforceScale;
2079
2369
  exports.extractLiteralOptions = extractLiteralOptions;
@@ -2095,11 +2385,11 @@ exports.getMetaEntry = getMetaEntry;
2095
2385
  exports.getResolver = getResolver;
2096
2386
  exports.getSortableColumns = getSortableColumns;
2097
2387
  exports.getThousandsSeparator = getThousandsSeparator;
2098
- exports.getVisibleColumns = getVisibleColumns;
2099
2388
  exports.groupInteger = groupInteger;
2100
2389
  exports.hasComputedAnnotations = hasComputedAnnotations;
2101
2390
  exports.isArrayField = isArrayField;
2102
2391
  exports.isObjectField = isObjectField;
2392
+ exports.isPathDirty = isPathDirty;
2103
2393
  exports.isPureLiteralUnion = isPureLiteralUnion;
2104
2394
  exports.isTupleField = isTupleField;
2105
2395
  exports.isUnionField = isUnionField;
@@ -2130,4 +2420,5 @@ exports.setDefaultValidatorPlugins = setDefaultValidatorPlugins;
2130
2420
  exports.setResolver = setResolver;
2131
2421
  exports.splitDecimalString = splitDecimalString;
2132
2422
  exports.str = str;
2423
+ exports.unionVariantChanged = unionVariantChanged;
2133
2424
  exports.valueHelpDictPaths = valueHelpDictPaths;