declarative-forms 2.1.1 → 2.1.4

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
@@ -488,7 +488,7 @@ var ArrayField = class extends Field {
488
488
  buttons: {
489
489
  OK: {
490
490
  id: confirmId,
491
- isActive: ({ data }) => this.validateEntry(data),
491
+ isActive: ({ data }) => this.validateEntry(data, form),
492
492
  action: (values) => {
493
493
  if (editing && index !== void 0) this.entries[index] = values;
494
494
  else this.entries.push(values);
@@ -509,9 +509,21 @@ var ArrayField = class extends Field {
509
509
  }));
510
510
  return this.descriptor.mapFieldsOnEdit?.(seeded, entry) ?? seeded;
511
511
  }
512
- validateEntry(entry) {
512
+ /**
513
+ * Run `isValidRecord` against the entry dialog.
514
+ *
515
+ * `ctx.form` is the entry dialog rather than the form owning the array field:
516
+ * the entry's fields — and their tooltip markers — live in the dialog, and
517
+ * tooltips are scoped per form, so `ctx.form.setTooltipError(…)` on the outer
518
+ * form was a silent no-op. The rest of the context still describes the outer
519
+ * field.
520
+ */
521
+ validateEntry(entry, dialog) {
513
522
  if (!this.descriptor.isValidRecord) return true;
514
- return this.descriptor.isValidRecord(entry, this.context.contextFor(this.descriptor));
523
+ return this.descriptor.isValidRecord(entry, {
524
+ ...this.context.contextFor(this.descriptor),
525
+ form: dialog
526
+ });
515
527
  }
516
528
  notifyChange() {
517
529
  const onChange = this.descriptor.onChange;
@@ -1329,6 +1341,8 @@ function defineDlSelect() {
1329
1341
  // src/fields/SelectField.ts
1330
1342
  var SelectField = class extends Field {
1331
1343
  select;
1344
+ /** True while the tooltip shows a message from {@link reportOptionsError}. */
1345
+ showsOptionsError = false;
1332
1346
  get wrapperExtraClass() {
1333
1347
  return "dl-select-wrapper";
1334
1348
  }
@@ -1366,7 +1380,7 @@ var SelectField = class extends Field {
1366
1380
  }
1367
1381
  const dependsOnTrigger = triggerName !== void 0 && this.descriptor.reloadOnChangeOf?.includes(triggerName) === true;
1368
1382
  if (dependsOnTrigger && typeof this.descriptor.options === "function") {
1369
- void this.loadOptions({ reload: true });
1383
+ void this.loadOptions();
1370
1384
  }
1371
1385
  }
1372
1386
  /**
@@ -1377,16 +1391,20 @@ var SelectField = class extends Field {
1377
1391
  * fresher options. v1 had no such guard, so a slow response from an earlier
1378
1392
  * keystroke could repopulate the select with outdated values.
1379
1393
  */
1380
- async loadOptions(opts = {}) {
1394
+ async loadOptions() {
1381
1395
  const isCurrent = this.context.scheduler.claim(`options:${this.descriptor.name}`);
1382
1396
  const source = this.descriptor.options;
1383
- const requested = typeof source === "function" ? source(this.context.contextFor(this.descriptor)) : source;
1384
- const isAsync = requested instanceof Promise;
1385
- if (isAsync) this.select.setLoadingStatus();
1397
+ let isAsync = false;
1386
1398
  try {
1399
+ const requested = typeof source === "function" ? source(this.context.contextFor(this.descriptor)) : source;
1400
+ isAsync = requested instanceof Promise;
1401
+ if (isAsync) this.select.setLoadingStatus();
1387
1402
  const options = await requested ?? [];
1388
1403
  if (!isCurrent()) return;
1389
- if (opts.reload) this.context.tooltips.reset(this.descriptor.name);
1404
+ if (this.showsOptionsError) {
1405
+ this.context.tooltips.reset(this.descriptor.name);
1406
+ this.showsOptionsError = false;
1407
+ }
1390
1408
  const previous = this.select.getValue();
1391
1409
  this.select.removeAllOptions();
1392
1410
  for (const option of options) this.select.addOption(this.createOption(option));
@@ -1420,6 +1438,7 @@ var SelectField = class extends Field {
1420
1438
  });
1421
1439
  if (message) {
1422
1440
  this.context.tooltips.setMessage(this.descriptor.name, message.level, message.text);
1441
+ this.showsOptionsError = true;
1423
1442
  }
1424
1443
  }
1425
1444
  };
@@ -1601,6 +1620,7 @@ var IdGenerator = class {
1601
1620
  // src/core/UpdateScheduler.ts
1602
1621
  var UpdateScheduler = class {
1603
1622
  pending = /* @__PURE__ */ new Set();
1623
+ updates = /* @__PURE__ */ new Set();
1604
1624
  generations = /* @__PURE__ */ new Map();
1605
1625
  /** Register async work and drop it from the set once it settles. */
1606
1626
  track(work) {
@@ -1611,25 +1631,64 @@ var UpdateScheduler = class {
1611
1631
  work.then(forget, forget);
1612
1632
  return work;
1613
1633
  }
1634
+ /**
1635
+ * Register an `update()` pass.
1636
+ *
1637
+ * Update passes are tracked separately from field work because `update()`
1638
+ * waits for field work *as part of* running, so a single set would make it
1639
+ * wait for itself. `whenSettled` drains both; `whenFieldWorkSettled`, which
1640
+ * is what `update()` itself calls, drains only `pending`.
1641
+ *
1642
+ * Tracking these at all is the point: `requestUpdate` fires an update and
1643
+ * returns, so before this existed `whenSettled()` could resolve with an
1644
+ * update still running and the button bar not yet refreshed. `ButtonBar`
1645
+ * awaits `whenSettled()` and then re-reads the primary button's `disabled`
1646
+ * class to decide whether to accept a confirm -- against a stale class, that
1647
+ * check passes and an invalid record is confirmed.
1648
+ */
1649
+ trackUpdate(work) {
1650
+ this.updates.add(work);
1651
+ const forget = () => {
1652
+ this.updates.delete(work);
1653
+ };
1654
+ work.then(forget, forget);
1655
+ return work;
1656
+ }
1614
1657
  /** Number of currently in-flight operations. Test/diagnostics only. */
1615
1658
  get pendingCount() {
1616
- return this.pending.size;
1659
+ return this.pending.size + this.updates.size;
1660
+ }
1661
+ /**
1662
+ * Resolve once nothing is in flight: no field work, and no update pass that
1663
+ * might still be about to act on it.
1664
+ */
1665
+ async whenSettled() {
1666
+ await this.drain(() => [...this.pending, ...this.updates]);
1617
1667
  }
1618
1668
  /**
1619
- * Resolve once nothing is in flight.
1669
+ * Resolve once field work is done, ignoring update passes.
1620
1670
  *
1671
+ * For `update()`'s own use. It cannot wait on `whenSettled`, because it is
1672
+ * itself one of the update passes that would be waited for.
1673
+ */
1674
+ async whenFieldWorkSettled() {
1675
+ await this.drain(() => [...this.pending]);
1676
+ }
1677
+ /**
1621
1678
  * Loops because settling one batch may schedule another (an options load
1622
1679
  * whose completion triggers a dependent field's reload).
1623
1680
  */
1624
- async whenSettled() {
1681
+ async drain(snapshot) {
1625
1682
  let guard = 0;
1626
- while (this.pending.size > 0) {
1683
+ let outstanding = snapshot();
1684
+ while (outstanding.length > 0) {
1627
1685
  if (++guard > 100) {
1628
1686
  throw new Error(
1629
1687
  "declarative-forms: form updates did not settle after 100 rounds \u2014 a field callback is most likely scheduling work on every update."
1630
1688
  );
1631
1689
  }
1632
- await Promise.allSettled([...this.pending]);
1690
+ await Promise.allSettled(outstanding);
1691
+ outstanding = snapshot();
1633
1692
  }
1634
1693
  }
1635
1694
  /**
@@ -1650,6 +1709,7 @@ var UpdateScheduler = class {
1650
1709
  /** Invalidate every outstanding claim and forget all pending work. */
1651
1710
  reset() {
1652
1711
  this.pending.clear();
1712
+ this.updates.clear();
1653
1713
  for (const key of this.generations.keys()) {
1654
1714
  this.generations.set(key, (this.generations.get(key) ?? 0) + 1);
1655
1715
  }
@@ -1724,16 +1784,20 @@ var ButtonBar = class {
1724
1784
  for (const { descriptor, element } of this.buttons) {
1725
1785
  if (!descriptor.id) continue;
1726
1786
  if (descriptor.isActive) {
1787
+ const isCurrent = this.callbacks.claim(`button:isActive:${descriptor.id}`);
1727
1788
  work.push(
1728
1789
  this.applyPredicate(descriptor.isActive(ctx), (active) => {
1790
+ if (!isCurrent()) return;
1729
1791
  element.classList.toggle("disabled", !active);
1730
1792
  element.toggleAttribute("disabled", !active);
1731
1793
  })
1732
1794
  );
1733
1795
  }
1734
1796
  if (descriptor.isVisible) {
1797
+ const isCurrent = this.callbacks.claim(`button:isVisible:${descriptor.id}`);
1735
1798
  work.push(
1736
1799
  this.applyPredicate(descriptor.isVisible(ctx), (visible) => {
1800
+ if (!isCurrent()) return;
1737
1801
  element.classList.toggle("invisible", !visible);
1738
1802
  })
1739
1803
  );
@@ -2130,6 +2194,7 @@ var DeclarativeForm = class _DeclarativeForm {
2130
2194
  this.tabBar = new TabBar((tab) => this.setActiveTab(tab));
2131
2195
  this.buttonBar = new ButtonBar(this.resolveButtons(), {
2132
2196
  whenSettled: () => this.scheduler.whenSettled(),
2197
+ claim: (key) => this.scheduler.claim(key),
2133
2198
  updateComputedFields: () => this.updateComputedFields(),
2134
2199
  getValues: () => this.getValues(),
2135
2200
  closeWith: (action) => this.close(action)
@@ -2326,6 +2391,9 @@ var DeclarativeForm = class _DeclarativeForm {
2326
2391
  * @param includeTab treat a tab switch as a change
2327
2392
  */
2328
2393
  async update(source, force = false, includeTab = false) {
2394
+ return this.scheduler.trackUpdate(this.runUpdate(source, force, includeTab));
2395
+ }
2396
+ async runUpdate(source, force = false, includeTab = false) {
2329
2397
  const values = this.getValues();
2330
2398
  const changed = this.model.hasChanged(values, includeTab);
2331
2399
  if (!changed && !force) return;
@@ -2349,7 +2417,7 @@ var DeclarativeForm = class _DeclarativeForm {
2349
2417
  field.onFormUpdate(ctx, triggerName);
2350
2418
  }
2351
2419
  this.renderTabs(activeTabs);
2352
- await this.scheduler.whenSettled();
2420
+ await this.scheduler.whenFieldWorkSettled();
2353
2421
  if (changed) this.model.notify(this.getValues());
2354
2422
  await this.buttonBar.refresh(this.buttonContext());
2355
2423
  }