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.d.cts CHANGED
@@ -372,18 +372,45 @@ declare class TooltipController {
372
372
  */
373
373
  declare class UpdateScheduler {
374
374
  private pending;
375
+ private updates;
375
376
  private generations;
376
377
  /** Register async work and drop it from the set once it settles. */
377
378
  track<T>(work: Promise<T>): Promise<T>;
379
+ /**
380
+ * Register an `update()` pass.
381
+ *
382
+ * Update passes are tracked separately from field work because `update()`
383
+ * waits for field work *as part of* running, so a single set would make it
384
+ * wait for itself. `whenSettled` drains both; `whenFieldWorkSettled`, which
385
+ * is what `update()` itself calls, drains only `pending`.
386
+ *
387
+ * Tracking these at all is the point: `requestUpdate` fires an update and
388
+ * returns, so before this existed `whenSettled()` could resolve with an
389
+ * update still running and the button bar not yet refreshed. `ButtonBar`
390
+ * awaits `whenSettled()` and then re-reads the primary button's `disabled`
391
+ * class to decide whether to accept a confirm -- against a stale class, that
392
+ * check passes and an invalid record is confirmed.
393
+ */
394
+ trackUpdate<T>(work: Promise<T>): Promise<T>;
378
395
  /** Number of currently in-flight operations. Test/diagnostics only. */
379
396
  get pendingCount(): number;
380
397
  /**
381
- * Resolve once nothing is in flight.
398
+ * Resolve once nothing is in flight: no field work, and no update pass that
399
+ * might still be about to act on it.
400
+ */
401
+ whenSettled(): Promise<void>;
402
+ /**
403
+ * Resolve once field work is done, ignoring update passes.
382
404
  *
405
+ * For `update()`'s own use. It cannot wait on `whenSettled`, because it is
406
+ * itself one of the update passes that would be waited for.
407
+ */
408
+ whenFieldWorkSettled(): Promise<void>;
409
+ /**
383
410
  * Loops because settling one batch may schedule another (an options load
384
411
  * whose completion triggers a dependent field's reload).
385
412
  */
386
- whenSettled(): Promise<void>;
413
+ private drain;
387
414
  /**
388
415
  * Claim the next generation for `key` and return a predicate that reports
389
416
  * whether that claim is still the newest.
@@ -425,10 +452,12 @@ interface FormContext {
425
452
  /**
426
453
  * Build a nested form that knows this form as its parent.
427
454
  *
428
- * Lets `ArrayField` open entry dialogs without importing `DeclarativeForm`,
429
- * keeping the field layer free of a cycle back into the core.
455
+ * Lets `ArrayField` open entry dialogs without importing `DeclarativeForm` at
456
+ * runtime, keeping the field layer free of a cycle back into the core. The
457
+ * return type is the full form so the dialog can be handed to descriptor
458
+ * callbacks as `ctx.form`.
430
459
  */
431
- createSubForm(options: SubFormOptions): SubForm;
460
+ createSubForm(options: SubFormOptions): DeclarativeForm;
432
461
  }
433
462
  interface SubFormOptions {
434
463
  fields: readonly FieldDescriptor[];
@@ -749,6 +778,7 @@ declare class DeclarativeForm implements SubForm, StackableDialog {
749
778
  * @param includeTab treat a tab switch as a change
750
779
  */
751
780
  update(source?: FieldHandle, force?: boolean, includeTab?: boolean): Promise<void>;
781
+ private runUpdate;
752
782
  private buttonContext;
753
783
  /** Recompute every `computed` field. Runs before a button action fires. */
754
784
  updateComputedFields(): Promise<void>;
@@ -877,6 +907,15 @@ declare class ArrayField extends Field<ArrayFieldDescriptor> {
877
907
  private openEntryDialog;
878
908
  /** Seed each field with the entry's current value, then apply `mapFieldsOnEdit`. */
879
909
  private editFields;
910
+ /**
911
+ * Run `isValidRecord` against the entry dialog.
912
+ *
913
+ * `ctx.form` is the entry dialog rather than the form owning the array field:
914
+ * the entry's fields — and their tooltip markers — live in the dialog, and
915
+ * tooltips are scoped per form, so `ctx.form.setTooltipError(…)` on the outer
916
+ * form was a silent no-op. The rest of the context still describes the outer
917
+ * field.
918
+ */
880
919
  private validateEntry;
881
920
  private notifyChange;
882
921
  }
@@ -989,6 +1028,8 @@ declare class MessageField extends Field<MessageFieldDescriptor> {
989
1028
  /** A `<dl-select>` combobox, single or multiple. */
990
1029
  declare class SelectField extends Field<SelectFieldDescriptor> {
991
1030
  private select;
1031
+ /** True while the tooltip shows a message from {@link reportOptionsError}. */
1032
+ private showsOptionsError;
992
1033
  protected get wrapperExtraClass(): string;
993
1034
  protected createControl(): HTMLElement;
994
1035
  initialize(): Promise<void>;
@@ -1179,6 +1220,12 @@ declare function injectStyles(): void;
1179
1220
  interface ButtonBarCallbacks {
1180
1221
  /** Resolve once all pending form work has settled. */
1181
1222
  whenSettled(): Promise<void>;
1223
+ /**
1224
+ * Claim the newest generation for `key`; the returned predicate reports
1225
+ * whether that claim is still current. Used to discard a stale async
1226
+ * predicate result -- see `refresh`.
1227
+ */
1228
+ claim(key: string): () => boolean;
1182
1229
  /** Recompute `computed` fields before an action runs. */
1183
1230
  updateComputedFields(): Promise<void>;
1184
1231
  getValues(): Record<string, unknown>;
package/dist/index.d.ts CHANGED
@@ -372,18 +372,45 @@ declare class TooltipController {
372
372
  */
373
373
  declare class UpdateScheduler {
374
374
  private pending;
375
+ private updates;
375
376
  private generations;
376
377
  /** Register async work and drop it from the set once it settles. */
377
378
  track<T>(work: Promise<T>): Promise<T>;
379
+ /**
380
+ * Register an `update()` pass.
381
+ *
382
+ * Update passes are tracked separately from field work because `update()`
383
+ * waits for field work *as part of* running, so a single set would make it
384
+ * wait for itself. `whenSettled` drains both; `whenFieldWorkSettled`, which
385
+ * is what `update()` itself calls, drains only `pending`.
386
+ *
387
+ * Tracking these at all is the point: `requestUpdate` fires an update and
388
+ * returns, so before this existed `whenSettled()` could resolve with an
389
+ * update still running and the button bar not yet refreshed. `ButtonBar`
390
+ * awaits `whenSettled()` and then re-reads the primary button's `disabled`
391
+ * class to decide whether to accept a confirm -- against a stale class, that
392
+ * check passes and an invalid record is confirmed.
393
+ */
394
+ trackUpdate<T>(work: Promise<T>): Promise<T>;
378
395
  /** Number of currently in-flight operations. Test/diagnostics only. */
379
396
  get pendingCount(): number;
380
397
  /**
381
- * Resolve once nothing is in flight.
398
+ * Resolve once nothing is in flight: no field work, and no update pass that
399
+ * might still be about to act on it.
400
+ */
401
+ whenSettled(): Promise<void>;
402
+ /**
403
+ * Resolve once field work is done, ignoring update passes.
382
404
  *
405
+ * For `update()`'s own use. It cannot wait on `whenSettled`, because it is
406
+ * itself one of the update passes that would be waited for.
407
+ */
408
+ whenFieldWorkSettled(): Promise<void>;
409
+ /**
383
410
  * Loops because settling one batch may schedule another (an options load
384
411
  * whose completion triggers a dependent field's reload).
385
412
  */
386
- whenSettled(): Promise<void>;
413
+ private drain;
387
414
  /**
388
415
  * Claim the next generation for `key` and return a predicate that reports
389
416
  * whether that claim is still the newest.
@@ -425,10 +452,12 @@ interface FormContext {
425
452
  /**
426
453
  * Build a nested form that knows this form as its parent.
427
454
  *
428
- * Lets `ArrayField` open entry dialogs without importing `DeclarativeForm`,
429
- * keeping the field layer free of a cycle back into the core.
455
+ * Lets `ArrayField` open entry dialogs without importing `DeclarativeForm` at
456
+ * runtime, keeping the field layer free of a cycle back into the core. The
457
+ * return type is the full form so the dialog can be handed to descriptor
458
+ * callbacks as `ctx.form`.
430
459
  */
431
- createSubForm(options: SubFormOptions): SubForm;
460
+ createSubForm(options: SubFormOptions): DeclarativeForm;
432
461
  }
433
462
  interface SubFormOptions {
434
463
  fields: readonly FieldDescriptor[];
@@ -749,6 +778,7 @@ declare class DeclarativeForm implements SubForm, StackableDialog {
749
778
  * @param includeTab treat a tab switch as a change
750
779
  */
751
780
  update(source?: FieldHandle, force?: boolean, includeTab?: boolean): Promise<void>;
781
+ private runUpdate;
752
782
  private buttonContext;
753
783
  /** Recompute every `computed` field. Runs before a button action fires. */
754
784
  updateComputedFields(): Promise<void>;
@@ -877,6 +907,15 @@ declare class ArrayField extends Field<ArrayFieldDescriptor> {
877
907
  private openEntryDialog;
878
908
  /** Seed each field with the entry's current value, then apply `mapFieldsOnEdit`. */
879
909
  private editFields;
910
+ /**
911
+ * Run `isValidRecord` against the entry dialog.
912
+ *
913
+ * `ctx.form` is the entry dialog rather than the form owning the array field:
914
+ * the entry's fields — and their tooltip markers — live in the dialog, and
915
+ * tooltips are scoped per form, so `ctx.form.setTooltipError(…)` on the outer
916
+ * form was a silent no-op. The rest of the context still describes the outer
917
+ * field.
918
+ */
880
919
  private validateEntry;
881
920
  private notifyChange;
882
921
  }
@@ -989,6 +1028,8 @@ declare class MessageField extends Field<MessageFieldDescriptor> {
989
1028
  /** A `<dl-select>` combobox, single or multiple. */
990
1029
  declare class SelectField extends Field<SelectFieldDescriptor> {
991
1030
  private select;
1031
+ /** True while the tooltip shows a message from {@link reportOptionsError}. */
1032
+ private showsOptionsError;
992
1033
  protected get wrapperExtraClass(): string;
993
1034
  protected createControl(): HTMLElement;
994
1035
  initialize(): Promise<void>;
@@ -1179,6 +1220,12 @@ declare function injectStyles(): void;
1179
1220
  interface ButtonBarCallbacks {
1180
1221
  /** Resolve once all pending form work has settled. */
1181
1222
  whenSettled(): Promise<void>;
1223
+ /**
1224
+ * Claim the newest generation for `key`; the returned predicate reports
1225
+ * whether that claim is still current. Used to discard a stale async
1226
+ * predicate result -- see `refresh`.
1227
+ */
1228
+ claim(key: string): () => boolean;
1182
1229
  /** Recompute `computed` fields before an action runs. */
1183
1230
  updateComputedFields(): Promise<void>;
1184
1231
  getValues(): Record<string, unknown>;
package/dist/index.js CHANGED
@@ -486,7 +486,7 @@ var ArrayField = class extends Field {
486
486
  buttons: {
487
487
  OK: {
488
488
  id: confirmId,
489
- isActive: ({ data }) => this.validateEntry(data),
489
+ isActive: ({ data }) => this.validateEntry(data, form),
490
490
  action: (values) => {
491
491
  if (editing && index !== void 0) this.entries[index] = values;
492
492
  else this.entries.push(values);
@@ -507,9 +507,21 @@ var ArrayField = class extends Field {
507
507
  }));
508
508
  return this.descriptor.mapFieldsOnEdit?.(seeded, entry) ?? seeded;
509
509
  }
510
- validateEntry(entry) {
510
+ /**
511
+ * Run `isValidRecord` against the entry dialog.
512
+ *
513
+ * `ctx.form` is the entry dialog rather than the form owning the array field:
514
+ * the entry's fields — and their tooltip markers — live in the dialog, and
515
+ * tooltips are scoped per form, so `ctx.form.setTooltipError(…)` on the outer
516
+ * form was a silent no-op. The rest of the context still describes the outer
517
+ * field.
518
+ */
519
+ validateEntry(entry, dialog) {
511
520
  if (!this.descriptor.isValidRecord) return true;
512
- return this.descriptor.isValidRecord(entry, this.context.contextFor(this.descriptor));
521
+ return this.descriptor.isValidRecord(entry, {
522
+ ...this.context.contextFor(this.descriptor),
523
+ form: dialog
524
+ });
513
525
  }
514
526
  notifyChange() {
515
527
  const onChange = this.descriptor.onChange;
@@ -1327,6 +1339,8 @@ function defineDlSelect() {
1327
1339
  // src/fields/SelectField.ts
1328
1340
  var SelectField = class extends Field {
1329
1341
  select;
1342
+ /** True while the tooltip shows a message from {@link reportOptionsError}. */
1343
+ showsOptionsError = false;
1330
1344
  get wrapperExtraClass() {
1331
1345
  return "dl-select-wrapper";
1332
1346
  }
@@ -1364,7 +1378,7 @@ var SelectField = class extends Field {
1364
1378
  }
1365
1379
  const dependsOnTrigger = triggerName !== void 0 && this.descriptor.reloadOnChangeOf?.includes(triggerName) === true;
1366
1380
  if (dependsOnTrigger && typeof this.descriptor.options === "function") {
1367
- void this.loadOptions({ reload: true });
1381
+ void this.loadOptions();
1368
1382
  }
1369
1383
  }
1370
1384
  /**
@@ -1375,16 +1389,20 @@ var SelectField = class extends Field {
1375
1389
  * fresher options. v1 had no such guard, so a slow response from an earlier
1376
1390
  * keystroke could repopulate the select with outdated values.
1377
1391
  */
1378
- async loadOptions(opts = {}) {
1392
+ async loadOptions() {
1379
1393
  const isCurrent = this.context.scheduler.claim(`options:${this.descriptor.name}`);
1380
1394
  const source = this.descriptor.options;
1381
- const requested = typeof source === "function" ? source(this.context.contextFor(this.descriptor)) : source;
1382
- const isAsync = requested instanceof Promise;
1383
- if (isAsync) this.select.setLoadingStatus();
1395
+ let isAsync = false;
1384
1396
  try {
1397
+ const requested = typeof source === "function" ? source(this.context.contextFor(this.descriptor)) : source;
1398
+ isAsync = requested instanceof Promise;
1399
+ if (isAsync) this.select.setLoadingStatus();
1385
1400
  const options = await requested ?? [];
1386
1401
  if (!isCurrent()) return;
1387
- if (opts.reload) this.context.tooltips.reset(this.descriptor.name);
1402
+ if (this.showsOptionsError) {
1403
+ this.context.tooltips.reset(this.descriptor.name);
1404
+ this.showsOptionsError = false;
1405
+ }
1388
1406
  const previous = this.select.getValue();
1389
1407
  this.select.removeAllOptions();
1390
1408
  for (const option of options) this.select.addOption(this.createOption(option));
@@ -1418,6 +1436,7 @@ var SelectField = class extends Field {
1418
1436
  });
1419
1437
  if (message) {
1420
1438
  this.context.tooltips.setMessage(this.descriptor.name, message.level, message.text);
1439
+ this.showsOptionsError = true;
1421
1440
  }
1422
1441
  }
1423
1442
  };
@@ -1599,6 +1618,7 @@ var IdGenerator = class {
1599
1618
  // src/core/UpdateScheduler.ts
1600
1619
  var UpdateScheduler = class {
1601
1620
  pending = /* @__PURE__ */ new Set();
1621
+ updates = /* @__PURE__ */ new Set();
1602
1622
  generations = /* @__PURE__ */ new Map();
1603
1623
  /** Register async work and drop it from the set once it settles. */
1604
1624
  track(work) {
@@ -1609,25 +1629,64 @@ var UpdateScheduler = class {
1609
1629
  work.then(forget, forget);
1610
1630
  return work;
1611
1631
  }
1632
+ /**
1633
+ * Register an `update()` pass.
1634
+ *
1635
+ * Update passes are tracked separately from field work because `update()`
1636
+ * waits for field work *as part of* running, so a single set would make it
1637
+ * wait for itself. `whenSettled` drains both; `whenFieldWorkSettled`, which
1638
+ * is what `update()` itself calls, drains only `pending`.
1639
+ *
1640
+ * Tracking these at all is the point: `requestUpdate` fires an update and
1641
+ * returns, so before this existed `whenSettled()` could resolve with an
1642
+ * update still running and the button bar not yet refreshed. `ButtonBar`
1643
+ * awaits `whenSettled()` and then re-reads the primary button's `disabled`
1644
+ * class to decide whether to accept a confirm -- against a stale class, that
1645
+ * check passes and an invalid record is confirmed.
1646
+ */
1647
+ trackUpdate(work) {
1648
+ this.updates.add(work);
1649
+ const forget = () => {
1650
+ this.updates.delete(work);
1651
+ };
1652
+ work.then(forget, forget);
1653
+ return work;
1654
+ }
1612
1655
  /** Number of currently in-flight operations. Test/diagnostics only. */
1613
1656
  get pendingCount() {
1614
- return this.pending.size;
1657
+ return this.pending.size + this.updates.size;
1658
+ }
1659
+ /**
1660
+ * Resolve once nothing is in flight: no field work, and no update pass that
1661
+ * might still be about to act on it.
1662
+ */
1663
+ async whenSettled() {
1664
+ await this.drain(() => [...this.pending, ...this.updates]);
1615
1665
  }
1616
1666
  /**
1617
- * Resolve once nothing is in flight.
1667
+ * Resolve once field work is done, ignoring update passes.
1618
1668
  *
1669
+ * For `update()`'s own use. It cannot wait on `whenSettled`, because it is
1670
+ * itself one of the update passes that would be waited for.
1671
+ */
1672
+ async whenFieldWorkSettled() {
1673
+ await this.drain(() => [...this.pending]);
1674
+ }
1675
+ /**
1619
1676
  * Loops because settling one batch may schedule another (an options load
1620
1677
  * whose completion triggers a dependent field's reload).
1621
1678
  */
1622
- async whenSettled() {
1679
+ async drain(snapshot) {
1623
1680
  let guard = 0;
1624
- while (this.pending.size > 0) {
1681
+ let outstanding = snapshot();
1682
+ while (outstanding.length > 0) {
1625
1683
  if (++guard > 100) {
1626
1684
  throw new Error(
1627
1685
  "declarative-forms: form updates did not settle after 100 rounds \u2014 a field callback is most likely scheduling work on every update."
1628
1686
  );
1629
1687
  }
1630
- await Promise.allSettled([...this.pending]);
1688
+ await Promise.allSettled(outstanding);
1689
+ outstanding = snapshot();
1631
1690
  }
1632
1691
  }
1633
1692
  /**
@@ -1648,6 +1707,7 @@ var UpdateScheduler = class {
1648
1707
  /** Invalidate every outstanding claim and forget all pending work. */
1649
1708
  reset() {
1650
1709
  this.pending.clear();
1710
+ this.updates.clear();
1651
1711
  for (const key of this.generations.keys()) {
1652
1712
  this.generations.set(key, (this.generations.get(key) ?? 0) + 1);
1653
1713
  }
@@ -1722,16 +1782,20 @@ var ButtonBar = class {
1722
1782
  for (const { descriptor, element } of this.buttons) {
1723
1783
  if (!descriptor.id) continue;
1724
1784
  if (descriptor.isActive) {
1785
+ const isCurrent = this.callbacks.claim(`button:isActive:${descriptor.id}`);
1725
1786
  work.push(
1726
1787
  this.applyPredicate(descriptor.isActive(ctx), (active) => {
1788
+ if (!isCurrent()) return;
1727
1789
  element.classList.toggle("disabled", !active);
1728
1790
  element.toggleAttribute("disabled", !active);
1729
1791
  })
1730
1792
  );
1731
1793
  }
1732
1794
  if (descriptor.isVisible) {
1795
+ const isCurrent = this.callbacks.claim(`button:isVisible:${descriptor.id}`);
1733
1796
  work.push(
1734
1797
  this.applyPredicate(descriptor.isVisible(ctx), (visible) => {
1798
+ if (!isCurrent()) return;
1735
1799
  element.classList.toggle("invisible", !visible);
1736
1800
  })
1737
1801
  );
@@ -2128,6 +2192,7 @@ var DeclarativeForm = class _DeclarativeForm {
2128
2192
  this.tabBar = new TabBar((tab) => this.setActiveTab(tab));
2129
2193
  this.buttonBar = new ButtonBar(this.resolveButtons(), {
2130
2194
  whenSettled: () => this.scheduler.whenSettled(),
2195
+ claim: (key) => this.scheduler.claim(key),
2131
2196
  updateComputedFields: () => this.updateComputedFields(),
2132
2197
  getValues: () => this.getValues(),
2133
2198
  closeWith: (action) => this.close(action)
@@ -2324,6 +2389,9 @@ var DeclarativeForm = class _DeclarativeForm {
2324
2389
  * @param includeTab treat a tab switch as a change
2325
2390
  */
2326
2391
  async update(source, force = false, includeTab = false) {
2392
+ return this.scheduler.trackUpdate(this.runUpdate(source, force, includeTab));
2393
+ }
2394
+ async runUpdate(source, force = false, includeTab = false) {
2327
2395
  const values = this.getValues();
2328
2396
  const changed = this.model.hasChanged(values, includeTab);
2329
2397
  if (!changed && !force) return;
@@ -2347,7 +2415,7 @@ var DeclarativeForm = class _DeclarativeForm {
2347
2415
  field.onFormUpdate(ctx, triggerName);
2348
2416
  }
2349
2417
  this.renderTabs(activeTabs);
2350
- await this.scheduler.whenSettled();
2418
+ await this.scheduler.whenFieldWorkSettled();
2351
2419
  if (changed) this.model.notify(this.getValues());
2352
2420
  await this.buttonBar.refresh(this.buttonContext());
2353
2421
  }