nexus-shared 3.2.0 → 4.0.0

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.
Files changed (187) hide show
  1. package/CHANGELOG.md +1564 -0
  2. package/README.md +1705 -1123
  3. package/dist/Client.Index.d.ts +14 -0
  4. package/dist/Client.Index.js +14 -0
  5. package/dist/Components/Documents/DocumentFrame.d.ts +50 -0
  6. package/dist/Components/Documents/DocumentFrame.js +83 -0
  7. package/dist/Components/Documents/Menu.d.ts +4 -0
  8. package/dist/Components/Documents/Menu.js +40 -24
  9. package/dist/Components/Documents/PrintDocument.d.ts +95 -0
  10. package/dist/Components/Documents/PrintDocument.js +62 -0
  11. package/dist/Components/Documents/Tabs.d.ts +59 -0
  12. package/dist/Components/Documents/Tabs.js +70 -0
  13. package/dist/Components/Forms/ApiForm.js +19 -2
  14. package/dist/Components/Forms/Crud.d.ts +2 -2
  15. package/dist/Components/Forms/Crud.js +576 -243
  16. package/dist/Components/Forms/CrudActions.d.ts +136 -0
  17. package/dist/Components/Forms/CrudActions.js +184 -0
  18. package/dist/Components/Forms/CrudNarrowPicker.d.ts +31 -0
  19. package/dist/Components/Forms/CrudNarrowPicker.js +180 -0
  20. package/dist/Components/Forms/CrudParts.d.ts +30 -2
  21. package/dist/Components/Forms/CrudParts.js +55 -5
  22. package/dist/Components/Forms/CrudRowForms.d.ts +26 -0
  23. package/dist/Components/Forms/CrudRowForms.js +114 -0
  24. package/dist/Components/Forms/CrudScopes.d.ts +28 -0
  25. package/dist/Components/Forms/CrudScopes.js +76 -0
  26. package/dist/Components/Forms/CrudTree.d.ts +90 -0
  27. package/dist/Components/Forms/CrudTree.js +363 -0
  28. package/dist/Components/Forms/CrudWorkspace.d.ts +63 -0
  29. package/dist/Components/Forms/CrudWorkspace.js +169 -0
  30. package/dist/Components/Forms/ExcelImport.js +3 -3
  31. package/dist/Components/Forms/Form.d.ts +4 -4
  32. package/dist/Components/Forms/Form.js +123 -17
  33. package/dist/Components/Forms/FormLeaveGuard.d.ts +44 -0
  34. package/dist/Components/Forms/FormLeaveGuard.js +65 -0
  35. package/dist/Components/Forms/RecordFlows.d.ts +50 -0
  36. package/dist/Components/Forms/RecordFlows.js +282 -0
  37. package/dist/Components/Forms/SubmitForm.d.ts +37 -2
  38. package/dist/Components/Forms/SubmitForm.js +195 -10
  39. package/dist/Components/Inputs/CheckBox.js +1 -1
  40. package/dist/Components/Inputs/ChoiceField.d.ts +21 -1
  41. package/dist/Components/Inputs/ChoiceField.js +28 -2
  42. package/dist/Components/Inputs/Dropdown.d.ts +13 -0
  43. package/dist/Components/Inputs/Dropdown.js +5 -5
  44. package/dist/Components/Inputs/DropdownParts.d.ts +3 -1
  45. package/dist/Components/Inputs/DropdownParts.js +16 -4
  46. package/dist/Components/Inputs/Field.d.ts +7 -1
  47. package/dist/Components/Inputs/Field.js +2 -2
  48. package/dist/Components/Inputs/GroupForm.d.ts +6 -6
  49. package/dist/Components/Inputs/GroupForm.js +10 -11
  50. package/dist/Components/Inputs/InputRenderer.d.ts +9 -6
  51. package/dist/Components/Inputs/InputRenderer.js +37 -32
  52. package/dist/Components/Inputs/PictureInput.d.ts +41 -0
  53. package/dist/Components/Inputs/PictureInput.js +70 -0
  54. package/dist/Components/Inputs/RadioGroup.d.ts +16 -3
  55. package/dist/Components/Inputs/RadioGroup.js +8 -3
  56. package/dist/Components/Inputs/RowsInput.d.ts +1 -1
  57. package/dist/Components/Inputs/RowsInput.js +18 -3
  58. package/dist/Components/Inputs/TabularForm.d.ts +5 -5
  59. package/dist/Components/Inputs/TabularForm.js +29 -29
  60. package/dist/Components/Inputs/TextArea.d.ts +6 -0
  61. package/dist/Components/Inputs/TextArea.js +11 -2
  62. package/dist/Components/Inputs/TextBoxTypes.d.ts +6 -0
  63. package/dist/Components/Inputs/TextBoxTypes.js +17 -0
  64. package/dist/Components/Inputs/TreeView.d.ts +53 -2
  65. package/dist/Components/Inputs/TreeView.js +147 -13
  66. package/dist/Components/Layouts/MessageBox.d.ts +15 -2
  67. package/dist/Components/Layouts/MessageBox.js +56 -7
  68. package/dist/Components/Layouts/Popup.d.ts +13 -6
  69. package/dist/Components/Layouts/Popup.js +37 -9
  70. package/dist/Components/Layouts/PopupDock.d.ts +3 -1
  71. package/dist/Components/Layouts/PopupDock.js +2 -1
  72. package/dist/Components/Layouts/PopupHost.js +5 -4
  73. package/dist/Components/Viewers/DataTable.js +150 -48
  74. package/dist/Components/Viewers/DataTableFilters.d.ts +1 -1
  75. package/dist/Components/Viewers/DataTableFilters.js +17 -2
  76. package/dist/Components/Viewers/DataTableParts.d.ts +21 -5
  77. package/dist/Components/Viewers/DataTableParts.js +26 -14
  78. package/dist/Components/Viewers/OwnerFiles.d.ts +175 -0
  79. package/dist/Components/Viewers/OwnerFiles.js +589 -0
  80. package/dist/Components/Viewers/QrCodeImage.d.ts +22 -0
  81. package/dist/Components/Viewers/QrCodeImage.js +15 -0
  82. package/dist/Components/Viewers/RecordCards.d.ts +16 -0
  83. package/dist/Components/Viewers/RecordCards.js +509 -0
  84. package/dist/Helpers/BusyHooks.d.ts +6 -0
  85. package/dist/Helpers/BusyHooks.js +39 -0
  86. package/dist/Helpers/CrudBackend.d.ts +20 -2
  87. package/dist/Helpers/CrudBackend.js +46 -2
  88. package/dist/Helpers/CrudColumns.d.ts +53 -6
  89. package/dist/Helpers/CrudColumns.js +98 -7
  90. package/dist/Helpers/CrudDetailsTabs.d.ts +58 -0
  91. package/dist/Helpers/CrudDetailsTabs.js +126 -0
  92. package/dist/Helpers/CrudHelpers.d.ts +81 -2
  93. package/dist/Helpers/CrudHelpers.js +195 -0
  94. package/dist/Helpers/CrudNarrowPick.d.ts +83 -0
  95. package/dist/Helpers/CrudNarrowPick.js +157 -0
  96. package/dist/Helpers/CrudRowForms.d.ts +15 -0
  97. package/dist/Helpers/CrudRowForms.js +43 -0
  98. package/dist/Helpers/CrudScopes.d.ts +32 -0
  99. package/dist/Helpers/CrudScopes.js +79 -0
  100. package/dist/Helpers/DropdownHelpers.d.ts +3 -1
  101. package/dist/Helpers/DropdownHelpers.js +6 -2
  102. package/dist/Helpers/ExcelHelpers.d.ts +1 -1
  103. package/dist/Helpers/ExcelHelpers.js +12 -11
  104. package/dist/Helpers/FileAdapter.d.ts +78 -0
  105. package/dist/Helpers/FileAdapter.js +102 -0
  106. package/dist/Helpers/FilePrimaries.d.ts +48 -0
  107. package/dist/Helpers/FilePrimaries.js +138 -0
  108. package/dist/Helpers/FormConfirm.d.ts +32 -0
  109. package/dist/Helpers/FormConfirm.js +40 -0
  110. package/dist/Helpers/FormStore.d.ts +90 -5
  111. package/dist/Helpers/FormStore.js +318 -22
  112. package/dist/Helpers/InputConditions.d.ts +50 -0
  113. package/dist/Helpers/InputConditions.js +168 -0
  114. package/dist/Helpers/InputMemory.d.ts +106 -0
  115. package/dist/Helpers/InputMemory.js +385 -0
  116. package/dist/Helpers/InputNarrowing.d.ts +18 -0
  117. package/dist/Helpers/InputNarrowing.js +67 -0
  118. package/dist/Helpers/InputParamsHelpers.d.ts +44 -1
  119. package/dist/Helpers/InputParamsHelpers.js +157 -74
  120. package/dist/Helpers/NexusDriveAdapter.d.ts +55 -0
  121. package/dist/Helpers/NexusDriveAdapter.js +207 -0
  122. package/dist/Helpers/NumberHelpers.d.ts +95 -0
  123. package/dist/Helpers/NumberHelpers.js +126 -0
  124. package/dist/Helpers/Permissions.js +2 -1
  125. package/dist/Helpers/QrCode.d.ts +26 -0
  126. package/dist/Helpers/QrCode.js +463 -0
  127. package/dist/Helpers/RecordCards.d.ts +205 -0
  128. package/dist/Helpers/RecordCards.js +378 -0
  129. package/dist/Helpers/RequestCache.d.ts +14 -2
  130. package/dist/Helpers/RequestCache.js +22 -3
  131. package/dist/Helpers/RowsStore.d.ts +37 -2
  132. package/dist/Helpers/RowsStore.js +128 -10
  133. package/dist/Helpers/ServerPreview.d.ts +69 -0
  134. package/dist/Helpers/ServerPreview.js +99 -0
  135. package/dist/Helpers/TableColumns.js +4 -3
  136. package/dist/Helpers/TableExport.d.ts +7 -1
  137. package/dist/Helpers/TableExport.js +9 -3
  138. package/dist/Helpers/TableFilters.d.ts +1 -1
  139. package/dist/Helpers/TableFilters.js +24 -23
  140. package/dist/Helpers/TableHelpers.d.ts +23 -2
  141. package/dist/Helpers/TableHelpers.js +51 -10
  142. package/dist/Helpers/TableJoins.d.ts +13 -0
  143. package/dist/Helpers/TableJoins.js +34 -0
  144. package/dist/Helpers/TableStore.d.ts +21 -1
  145. package/dist/Helpers/TableStore.js +64 -7
  146. package/dist/Helpers/TreeStore.d.ts +81 -0
  147. package/dist/Helpers/TreeStore.js +365 -0
  148. package/dist/Interfaces/CrudInterfaces.d.ts +465 -5
  149. package/dist/Interfaces/FileInterfaces.d.ts +162 -0
  150. package/dist/Interfaces/FileInterfaces.js +4 -0
  151. package/dist/Interfaces/FormInterfaces.d.ts +391 -30
  152. package/dist/Interfaces/FormInterfaces.js +79 -1
  153. package/dist/Interfaces/InputInterfaces.d.ts +15 -3
  154. package/dist/Interfaces/RecordInterfaces.d.ts +367 -0
  155. package/dist/Interfaces/RecordInterfaces.js +1 -0
  156. package/dist/Interfaces/TableInterfaces.d.ts +74 -1
  157. package/dist/Services/ExcelApi.js +5 -0
  158. package/dist/Services/FileApi.d.ts +13 -0
  159. package/dist/Services/FileApi.js +29 -0
  160. package/dist/Services/ImageEditorLoader.d.ts +14 -0
  161. package/dist/Services/ImageEditorLoader.js +51 -0
  162. package/dist/Services/JoinSources.d.ts +37 -5
  163. package/dist/Services/JoinSources.js +90 -14
  164. package/dist/Services/ModuleImport.d.ts +7 -0
  165. package/dist/Services/ModuleImport.js +22 -0
  166. package/dist/Shared.Index.d.ts +16 -0
  167. package/dist/Shared.Index.js +16 -0
  168. package/package.json +7 -2
  169. package/src/Styles/Nexus.Avatar.css +6 -5
  170. package/src/Styles/Nexus.Button.css +10 -4
  171. package/src/Styles/Nexus.Choice.css +16 -0
  172. package/src/Styles/Nexus.Crud.css +383 -9
  173. package/src/Styles/Nexus.Dropdown.css +8 -0
  174. package/src/Styles/Nexus.Files.css +410 -0
  175. package/src/Styles/Nexus.Form.css +5 -2
  176. package/src/Styles/Nexus.Index.css +4 -0
  177. package/src/Styles/Nexus.Input.css +42 -0
  178. package/src/Styles/Nexus.Menu.css +10 -1
  179. package/src/Styles/Nexus.Popup.css +77 -1
  180. package/src/Styles/Nexus.Print.css +288 -0
  181. package/src/Styles/Nexus.QrCode.css +12 -0
  182. package/src/Styles/Nexus.Record.css +430 -0
  183. package/src/Styles/Nexus.Tab.Buttons.css +87 -22
  184. package/src/Styles/Nexus.Table.css +67 -5
  185. package/src/Styles/Nexus.Themes.css +12 -6
  186. package/src/Styles/Nexus.Tokens.css +8 -3
  187. package/src/Styles/Nexus.Tree.css +31 -1
@@ -1,4 +1,6 @@
1
- import { duplicateMessageFor, initialInputValue, inputValuesEqual, normalizeInputValue, rowsItemNames, rowsMessage, uniqueKeyOf, validateInput, } from "./InputParamsHelpers.js";
1
+ import { EInputTypes } from "../Interfaces/FormInterfaces.js";
2
+ import { findRememberedOpening, forgetInputMemory, isEmptyInputValue, isPickerInput, lastAddedRowKey, readInputMemory, rememberDependencies, rememberedOpeningNow, writeInputMemory, } from "./InputMemory.js";
3
+ import { duplicateMessageFor, holdsValue, initialInputValue, inputValuesEqual, normalizeInputValue, rowsItemNames, rowsMessage, uniqueKeyOf, validateInput, } from "./InputParamsHelpers.js";
2
4
  export class RowsStore {
3
5
  options;
4
6
  inputByName = new Map();
@@ -17,6 +19,12 @@ export class RowsStore {
17
19
  showAll = false;
18
20
  touched = new Map();
19
21
  removed = null;
22
+ // Remembered row inputs (`remember`): where they are kept, the rows added since the rows were loaded (in the order they
23
+ // were added: a save keeps the last one's values), and the cells a picker is still asked about.
24
+ memory = null;
25
+ added = [];
26
+ asking = new Map();
27
+ memoryRound = null;
20
28
  // Rendering: a revision per value set from code (its input mounts again with it), a version per row.
21
29
  revisions = new Map();
22
30
  versions = new Map();
@@ -182,18 +190,25 @@ export class RowsStore {
182
190
  if (!this.showAll && this.duplicateErrors.get(key)?.has(name))
183
191
  this.bump(key);
184
192
  }
185
- /** Adds a row with the inputs' defaults, `newRow`, and `values`, at `index` or at the end. Returns its key. */
193
+ /**
194
+ * Adds a row with the inputs' defaults, `newRow`, and `values`, at `index` or at the end. Returns its key. A remembered
195
+ * input (`remember`) that neither `newRow` nor `values` names with a value opens on the value kept for it, while its
196
+ * input offers it; a picker's opens once the picker answers, unless the person picked one first.
197
+ */
186
198
  add(values, index = this.entries.length) {
187
199
  const position = Math.max(0, Math.min(index, this.entries.length));
188
200
  const wasEmpty = this.entries.length === 0;
189
201
  const row = {};
190
202
  for (const params of this.options.inputs)
191
- row[params.name] = initialInputValue(params);
203
+ if (holdsValue(params))
204
+ row[params.name] = initialInputValue(params);
192
205
  const { newRow } = this.options;
193
- Object.assign(row, typeof newRow === "function" ? newRow(position) : newRow, values);
206
+ const named = { ...(typeof newRow === "function" ? newRow(position) : newRow), ...values };
207
+ Object.assign(row, named);
208
+ const asks = this.openRemembered(row, named);
194
209
  // The first row of a list has each radio on: the first address added is the primary one.
195
210
  for (const params of this.options.inputs) {
196
- if (params.type === "radio" && wasEmpty && values?.[params.name] === undefined)
211
+ if (params.type === EInputTypes.Radio && wasEmpty && values?.[params.name] === undefined)
197
212
  row[params.name] = true;
198
213
  }
199
214
  const entry = { key: this.newKey(), values: this.normalizeRow(row) };
@@ -203,15 +218,41 @@ export class RowsStore {
203
218
  for (const params of this.options.inputs) {
204
219
  this.checkCell(entry, params);
205
220
  // Only one row keeps a radio on: a new row that has it takes it from the others.
206
- if (params.type === "radio" && entry.values[params.name] === true)
221
+ if (params.type === EInputTypes.Radio && entry.values[params.name] === true)
207
222
  this.radioOn(entry, params.name, changed);
208
223
  }
209
224
  this.messageShown = true;
210
225
  this.removed = null;
226
+ this.added.push(entry.key);
211
227
  this.markUniqueDirty();
212
228
  this.settle(changed);
229
+ this.askPickers(entry.key, asks);
213
230
  return entry.key;
214
231
  }
232
+ /**
233
+ * The storage remembered row inputs read and keep (`InputLayoutParams.remember`), given once the rows are drawn in the
234
+ * browser: rows added from then on open on the kept values. The rows already there keep theirs.
235
+ */
236
+ attachMemory(storage) {
237
+ this.memory = storage;
238
+ }
239
+ /**
240
+ * A save of the form holding the rows: each remembered input keeps its value in the row added last that is still there
241
+ * (an empty value forgets), under that row's `per` value and where its `when` holds on that row. No row added since the
242
+ * rows were loaded: nothing is kept, since no new choice was made.
243
+ */
244
+ rememberAdded() {
245
+ if (!this.memory)
246
+ return;
247
+ const key = lastAddedRowKey(this.added, added => this.positions.has(added));
248
+ const row = key === null ? undefined : this.row(key);
249
+ if (!row)
250
+ return;
251
+ const read = (name) => row[name];
252
+ for (const params of this.options.inputs)
253
+ if (params.remember && !params.hidden)
254
+ writeInputMemory(this.memory, params, row[params.name], read);
255
+ }
215
256
  /** Adds a copy of a row after it, without its id and with its radios off. Returns the copy's key. */
216
257
  duplicate(key) {
217
258
  const index = this.positions.get(key);
@@ -222,7 +263,7 @@ export class RowsStore {
222
263
  if (this.options.rowKey)
223
264
  delete values[this.options.rowKey];
224
265
  for (const params of this.options.inputs)
225
- if (params.type === "radio")
266
+ if (params.type === EInputTypes.Radio)
226
267
  values[params.name] = false;
227
268
  return this.add(values, index + 1);
228
269
  }
@@ -243,7 +284,7 @@ export class RowsStore {
243
284
  const moved = [];
244
285
  const first = this.entries[0];
245
286
  for (const params of this.options.inputs) {
246
- if (params.type !== "radio" || entry.values[params.name] !== true || !first)
287
+ if (params.type !== EInputTypes.Radio || entry.values[params.name] !== true || !first)
247
288
  continue;
248
289
  this.write(first.key, params.name, true, "code", changed, 1);
249
290
  moved.push({ key: first.key, name: params.name });
@@ -306,6 +347,9 @@ export class RowsStore {
306
347
  */
307
348
  replace(rows, { asInitial = true, emit = false } = {}) {
308
349
  this.load(rows);
350
+ // Loaded rows: none of them was added here, and no picker's answer is wanted any more.
351
+ this.added = [];
352
+ this.stopAskingPickers();
309
353
  if (asInitial)
310
354
  this.initial = this.getValue();
311
355
  this.generation++;
@@ -363,7 +407,7 @@ export class RowsStore {
363
407
  });
364
408
  // Saved data with a radio on in more than one row keeps the first.
365
409
  for (const params of this.options.inputs) {
366
- if (params.type !== "radio")
410
+ if (params.type !== EInputTypes.Radio)
367
411
  continue;
368
412
  let found = false;
369
413
  for (const entry of this.entries) {
@@ -383,6 +427,76 @@ export class RowsStore {
383
427
  this.valueCache = null;
384
428
  this.keysCache = null;
385
429
  }
430
+ /**
431
+ * Opens a new row's remembered inputs on their kept values, where the row is not added with one: the company's
432
+ * `preferred`, else the person's last choice, while the input offers it (`rememberedOpeningNow`, `when` and `per` read
433
+ * in the row). A kept choice no longer offered is forgotten. Answers the pickers that must be asked first.
434
+ */
435
+ openRemembered(row, named) {
436
+ const asks = [];
437
+ const read = (name) => row[name];
438
+ for (const params of this.options.inputs) {
439
+ const { name } = params;
440
+ if (!params.remember || params.hidden || !holdsValue(params))
441
+ continue;
442
+ if (name in named && !isEmptyInputValue(params, named[name]))
443
+ continue;
444
+ if (!this.memory && isPickerInput(params))
445
+ continue;
446
+ const stored = this.memory ? readInputMemory(this.memory, params.remember, read) : undefined;
447
+ const opening = rememberedOpeningNow(params, stored, read);
448
+ if (opening.forget)
449
+ forgetInputMemory(this.memory, params.remember, read);
450
+ if (opening.ask)
451
+ asks.push({ params, stored });
452
+ if (opening.value !== undefined)
453
+ row[name] = opening.value;
454
+ }
455
+ return asks;
456
+ }
457
+ /** Stops asking pickers about remembered values: their answers no longer open anything. */
458
+ stopAskingPickers() {
459
+ this.memoryRound?.abort();
460
+ this.memoryRound = null;
461
+ this.asking.clear();
462
+ }
463
+ /**
464
+ * Asks each picker whether it still offers the remembered value of a new row's input (`findRememberedOpening`), judged
465
+ * on the row as it was added: an answer forgets a choice no longer offered (under that row's `per` value), and opens an
466
+ * offered one while the row is there, the person has not picked in that cell since, and the row's values it was judged
467
+ * on (`rememberDependencies`) are unchanged.
468
+ */
469
+ askPickers(key, asks) {
470
+ const added = this.row(key);
471
+ if (!asks.length || !added)
472
+ return;
473
+ this.memoryRound ??= new AbortController();
474
+ const round = this.memoryRound;
475
+ const memory = this.memory;
476
+ const names = this.asking.get(key) ?? new Set();
477
+ this.asking.set(key, names);
478
+ const opening = { ...added };
479
+ const read = (name) => opening[name];
480
+ for (const { params, stored } of asks) {
481
+ names.add(params.name);
482
+ const judgedOn = rememberDependencies(params);
483
+ void findRememberedOpening(params, stored, read, round.signal).then(answer => {
484
+ if (round.signal.aborted)
485
+ return;
486
+ if (answer.forget)
487
+ forgetInputMemory(memory, params.remember, read);
488
+ const waiting = this.asking.get(key);
489
+ if (!waiting?.delete(params.name))
490
+ return;
491
+ if (!waiting.size)
492
+ this.asking.delete(key);
493
+ const row = this.row(key);
494
+ if (answer.value === undefined || !row || !judgedOn.every(name => inputValuesEqual(row[name], opening[name])))
495
+ return;
496
+ this.set(key, params.name, answer.value);
497
+ });
498
+ }
499
+ }
386
500
  /** Drops what the store knew about a removed row. */
387
501
  forget(key) {
388
502
  this.invalidCells -= this.invalidByRow.get(key) ?? 0;
@@ -391,6 +505,7 @@ export class RowsStore {
391
505
  this.duplicateErrors.delete(key);
392
506
  this.touched.delete(key);
393
507
  this.revisions.delete(key);
508
+ this.asking.delete(key);
394
509
  }
395
510
  write(key, name, value, source, changed, depth) {
396
511
  const index = this.positions.get(key);
@@ -399,6 +514,9 @@ export class RowsStore {
399
514
  return;
400
515
  const params = this.inputByName.get(name);
401
516
  const next = params && source === "code" ? normalizeInputValue(params, value) : value;
517
+ // The person's own pick: a picker's answer about a remembered value no longer replaces it.
518
+ if (source === "input")
519
+ this.asking.get(key)?.delete(name);
402
520
  if (inputValuesEqual(entry.values[name], next))
403
521
  return;
404
522
  const shownBefore = this.shownError(key, name);
@@ -414,7 +532,7 @@ export class RowsStore {
414
532
  }
415
533
  if (params) {
416
534
  this.checkCell(entry, params);
417
- if (params.type === "radio") {
535
+ if (params.type === EInputTypes.Radio) {
418
536
  // The row shows which radios it has on (a section's "Primary" tag), so it renders again, typed or not.
419
537
  changed.add(key);
420
538
  if (next === true)
@@ -0,0 +1,69 @@
1
+ /** What a preview shows now. A new object after every change. */
2
+ export interface ServerPreviewState<TQuestion, TResult> {
3
+ /** The last answer, kept while a newer question waits or runs, and after a newer one failed. Null before the first. */
4
+ result: TResult | null;
5
+ /** The question `result` answers: compare it with the values a save sends, to save only what was shown. */
6
+ question: TQuestion | null;
7
+ /** A question waits for its moment: the pause after the last request (`delay`). */
8
+ waiting: boolean;
9
+ /** A question is with the server. A page that says "Working it out…" says it while `waiting || loading`. */
10
+ loading: boolean;
11
+ /** Why the last question failed, in words; null once a newer answer came, or while a newer question waits. */
12
+ error: string | null;
13
+ }
14
+ /** Asks the server: the answer, or a throw whose message says why (an abort is not a failure). */
15
+ export type ServerPreviewAsk<TQuestion, TResult> = (question: TQuestion, signal: AbortSignal) => Promise<TResult>;
16
+ export interface ServerPreviewOptions {
17
+ /** Milliseconds after the last `request` before the question goes. Default 300. */
18
+ delay?: number;
19
+ /** The words of a failure whose error has none. Default "The preview cannot be worked out just now." */
20
+ failure?: string;
21
+ }
22
+ /** The preview's questions and answers, outside React. */
23
+ export declare class ServerPreviewStore<TQuestion, TResult> {
24
+ private state;
25
+ private listeners;
26
+ private timer;
27
+ private running;
28
+ private asked;
29
+ /** Read on each question, so a page's latest function is the one asked. */
30
+ private readonly ask;
31
+ private readonly options;
32
+ constructor(ask: () => ServerPreviewAsk<TQuestion, TResult>, options?: ServerPreviewOptions);
33
+ getState: () => ServerPreviewState<TQuestion, TResult>;
34
+ subscribe: (listener: () => void) => (() => void);
35
+ private patch;
36
+ /** Stops the question waiting and the one running; their answers are never shown. */
37
+ private stop;
38
+ /** Asks about this question a moment after the last request (the delay), dropping any older one. */
39
+ request(question: TQuestion): void;
40
+ private send;
41
+ /** Drops the question waiting or running; the last answer stays. */
42
+ cancel(): void;
43
+ /** Drops every question and the last answer: nothing is shown. */
44
+ clear(): void;
45
+ }
46
+ /** A page's preview: its state, and `request`, `cancel` and `clear`. */
47
+ export interface ServerPreview<TQuestion, TResult> extends ServerPreviewState<TQuestion, TResult> {
48
+ /** Asks about these values a moment after the last request; an older question is dropped. */
49
+ request: (question: TQuestion) => void;
50
+ /** Drops the question waiting or running (the form lacks what the server needs); the last answer stays. */
51
+ cancel: () => void;
52
+ /** Drops every question and the last answer. */
53
+ clear: () => void;
54
+ }
55
+ /**
56
+ * A preview the server works out a moment after the last change: `request(values)` on every change, and the page shows
57
+ * `result` (kept while a newer question runs), `waiting` (the pause), `loading` (with the server) and `error`. An older question is aborted and its late answer
58
+ * never shown; leaving the page stops what runs. `ask` throws to fail, with the words to show.
59
+ *
60
+ * @example
61
+ * const preview = useServerPreview(async (body: CounterBody, signal) => {
62
+ * const answer = await api.send<CounterPreview>(COUNTER_PREVIEW, { body, signal, toast: false });
63
+ * if (!answer.ok || !answer.result) throw new Error(answer.message ?? "This cannot be worked out just now.");
64
+ * return answer.result;
65
+ * });
66
+ * <Form inputs={inputs} onValueChange={values => preview.request(counterBody(values))} />
67
+ * {preview.waiting || preview.loading ? "Working it out…" : preview.error ?? previewLine(preview.result)}
68
+ */
69
+ export declare function useServerPreview<TQuestion, TResult>(ask: ServerPreviewAsk<TQuestion, TResult>, options?: ServerPreviewOptions): ServerPreview<TQuestion, TResult>;
@@ -0,0 +1,99 @@
1
+ "use client";
2
+ import { useEffect, useRef, useState, useSyncExternalStore } from "react";
3
+ import { isAbortError } from "./ApiResponses.js";
4
+ const IDLE = { result: null, question: null, waiting: false, loading: false, error: null };
5
+ /** The preview's questions and answers, outside React. */
6
+ export class ServerPreviewStore {
7
+ state = IDLE;
8
+ listeners = new Set();
9
+ timer;
10
+ running = null;
11
+ asked = 0;
12
+ /** Read on each question, so a page's latest function is the one asked. */
13
+ ask;
14
+ options;
15
+ constructor(ask, options = {}) {
16
+ this.ask = ask;
17
+ this.options = options;
18
+ }
19
+ getState = () => this.state;
20
+ subscribe = (listener) => {
21
+ this.listeners.add(listener);
22
+ return () => this.listeners.delete(listener);
23
+ };
24
+ patch(changes) {
25
+ this.state = { ...this.state, ...changes };
26
+ for (const listener of [...this.listeners])
27
+ listener();
28
+ }
29
+ /** Stops the question waiting and the one running; their answers are never shown. */
30
+ stop() {
31
+ clearTimeout(this.timer);
32
+ this.timer = undefined;
33
+ this.running?.abort();
34
+ this.running = null;
35
+ this.asked++;
36
+ }
37
+ /** Asks about this question a moment after the last request (the delay), dropping any older one. */
38
+ request(question) {
39
+ this.stop();
40
+ const turn = this.asked;
41
+ this.patch({ waiting: true, loading: false, error: null });
42
+ this.timer = setTimeout(() => void this.send(question, turn), this.options.delay ?? 300);
43
+ }
44
+ async send(question, turn) {
45
+ this.timer = undefined;
46
+ const controller = new AbortController();
47
+ this.running = controller;
48
+ this.patch({ waiting: false, loading: true });
49
+ try {
50
+ const result = await this.ask()(question, controller.signal);
51
+ if (turn !== this.asked)
52
+ return;
53
+ this.running = null;
54
+ this.patch({ result, question, loading: false, error: null });
55
+ }
56
+ catch (error) {
57
+ if (turn !== this.asked || isAbortError(error) || controller.signal.aborted)
58
+ return;
59
+ this.running = null;
60
+ const message = error instanceof Error && error.message ? error.message : (this.options.failure ?? "The preview cannot be worked out just now.");
61
+ this.patch({ loading: false, error: message });
62
+ }
63
+ }
64
+ /** Drops the question waiting or running; the last answer stays. */
65
+ cancel() {
66
+ this.stop();
67
+ if (this.state.waiting || this.state.loading)
68
+ this.patch({ waiting: false, loading: false });
69
+ }
70
+ /** Drops every question and the last answer: nothing is shown. */
71
+ clear() {
72
+ this.stop();
73
+ this.patch(IDLE);
74
+ }
75
+ }
76
+ /**
77
+ * A preview the server works out a moment after the last change: `request(values)` on every change, and the page shows
78
+ * `result` (kept while a newer question runs), `waiting` (the pause), `loading` (with the server) and `error`. An older question is aborted and its late answer
79
+ * never shown; leaving the page stops what runs. `ask` throws to fail, with the words to show.
80
+ *
81
+ * @example
82
+ * const preview = useServerPreview(async (body: CounterBody, signal) => {
83
+ * const answer = await api.send<CounterPreview>(COUNTER_PREVIEW, { body, signal, toast: false });
84
+ * if (!answer.ok || !answer.result) throw new Error(answer.message ?? "This cannot be worked out just now.");
85
+ * return answer.result;
86
+ * });
87
+ * <Form inputs={inputs} onValueChange={values => preview.request(counterBody(values))} />
88
+ * {preview.waiting || preview.loading ? "Working it out…" : preview.error ?? previewLine(preview.result)}
89
+ */
90
+ export function useServerPreview(ask, options) {
91
+ const latest = useRef(ask);
92
+ latest.current = ask;
93
+ const [store] = useState(() => new ServerPreviewStore(() => latest.current, options));
94
+ const state = useSyncExternalStore(store.subscribe, store.getState, store.getState);
95
+ // The same functions on every draw, so a page can name them in an effect's dependencies.
96
+ const [actions] = useState(() => ({ request: (question) => store.request(question), cancel: () => store.cancel(), clear: () => store.clear() }));
97
+ useEffect(() => () => store.cancel(), [store]);
98
+ return { ...state, ...actions };
99
+ }
@@ -40,12 +40,13 @@ export function headLeafCells(head) {
40
40
  return cells;
41
41
  }
42
42
  /**
43
- * The headings a dragged one can be dropped between: the ones pinned to the same side, in the order they are drawn.
43
+ * The headings a dragged one can be dropped between: the ones pinned to the same side, in the order they are drawn -
44
+ * by the side chosen (`data-pin-side`), so a pinned column scrolling on a narrow table still moves among its side's.
44
45
  * They are taken from the whole header, so a column under a band and one beside it are the same list.
45
46
  */
46
47
  function dropTargets(head, heading) {
47
- const pin = heading.getAttribute("data-pin") ?? "";
48
- return headLeafCells(head).filter(cell => cell.hasAttribute(KEY) && (cell.getAttribute("data-pin") ?? "") === pin);
48
+ const side = heading.getAttribute("data-pin-side") ?? "";
49
+ return headLeafCells(head).filter(cell => cell.hasAttribute(KEY) && (cell.getAttribute("data-pin-side") ?? "") === side);
49
50
  }
50
51
  function clearDropMarks(head) {
51
52
  for (const cell of head?.querySelectorAll(`[${DROP_BEFORE}], [${DROP_AFTER}]`) ?? []) {
@@ -17,7 +17,13 @@ export interface TableOutput<TRow extends TableRow = TableRow> {
17
17
  export declare function tableToCsv<TRow extends TableRow>({ columns, rows, cellOptions }: TableOutput<TRow>): string;
18
18
  /** A file name without characters a file system refuses. */
19
19
  export declare function safeFileName(name: string): string;
20
- /** Hands the user a file to save, from text made in the browser. */
20
+ /**
21
+ * Hands the user a file to save, from text made in the browser: a table's CSV, a person's recovery codes as a `.txt`.
22
+ * The text goes into a Blob behind a link put in the page, clicked and taken out again, and the Blob's address is let go
23
+ * ten seconds later. The file starts with a byte order mark, so Excel and Windows editors read it as UTF-8.
24
+ *
25
+ * @example downloadText("nexus-recovery-codes.txt", codes.join("\n"), "text/plain;charset=utf-8");
26
+ */
21
27
  export declare function downloadText(name: string, text: string, type: string): void;
22
28
  /** Writes the table as a CSV file the user saves. */
23
29
  export declare function exportTableCsv<TRow extends TableRow>(output: TableOutput<TRow>): void;
@@ -10,14 +10,20 @@ function csvField(text) {
10
10
  export function tableToCsv({ columns, rows, cellOptions = {} }) {
11
11
  const lines = [columns.map(column => csvField(column.label)).join(",")];
12
12
  for (const row of rows)
13
- lines.push(columns.map(column => csvField(formatCellText(column, columnValue(column, row), cellOptions))).join(","));
13
+ lines.push(columns.map(column => csvField(formatCellText(column, columnValue(column, row), cellOptions, row))).join(","));
14
14
  return lines.join(CRLF) + CRLF;
15
15
  }
16
16
  /** A file name without characters a file system refuses. */
17
17
  export function safeFileName(name) {
18
18
  return name.replace(/[\/:*?"<>|]+/g, " ").trim().replace(/\s+/g, "-").toLowerCase() || "table";
19
19
  }
20
- /** Hands the user a file to save, from text made in the browser. */
20
+ /**
21
+ * Hands the user a file to save, from text made in the browser: a table's CSV, a person's recovery codes as a `.txt`.
22
+ * The text goes into a Blob behind a link put in the page, clicked and taken out again, and the Blob's address is let go
23
+ * ten seconds later. The file starts with a byte order mark, so Excel and Windows editors read it as UTF-8.
24
+ *
25
+ * @example downloadText("nexus-recovery-codes.txt", codes.join("\n"), "text/plain;charset=utf-8");
26
+ */
21
27
  export function downloadText(name, text, type) {
22
28
  // A BOM, so Excel reads the file as UTF-8 and shows accents and Devanagari.
23
29
  const blob = new Blob([String.fromCharCode(0xfeff) + text], { type });
@@ -57,7 +63,7 @@ export function tableToPrintHtml({ columns, rows, cellOptions = {}, name = "Tabl
57
63
  const cellClass = (column) => (align(column) === "end" ? ' class="nx-end"' : align(column) === "center" ? ' class="nx-center"' : "");
58
64
  const head = columns.map(column => "<th" + cellClass(column) + ">" + escapeHtml(column.label) + "</th>").join("");
59
65
  const body = rows
60
- .map(row => "<tr>" + columns.map(column => "<td" + cellClass(column) + ">" + escapeHtml(formatCellText(column, columnValue(column, row), cellOptions)) + "</td>").join("") + "</tr>")
66
+ .map(row => "<tr>" + columns.map(column => "<td" + cellClass(column) + ">" + escapeHtml(formatCellText(column, columnValue(column, row), cellOptions, row)) + "</td>").join("") + "</tr>")
61
67
  .join("");
62
68
  return [
63
69
  "<!doctype html><html><head><meta charset=\"utf-8\"><title>" + escapeHtml(name) + "</title><style>" + PRINT_STYLE + "</style></head><body>",
@@ -1,4 +1,4 @@
1
- import type { InputParams } from "../Interfaces/FormInterfaces.ts";
1
+ import { type InputParams } from "../Interfaces/FormInterfaces.ts";
2
2
  import type { TableColumn, TableFilters, TableRow } from "../Interfaces/TableInterfaces.ts";
3
3
  /** The params as a filter: the same object without `required`, `defaultValue`, `defaultChecked`. */
4
4
  export declare function asFilterInput(params: InputParams): InputParams;
@@ -1,14 +1,15 @@
1
+ import { EInputTypes } from "../Interfaces/FormInterfaces.js";
1
2
  import { addDays } from "./DateHelpers.js";
2
3
  import { fromLocalDateTime, toLocalDateTime, toUtcDateTime } from "./DateTimeHelpers.js";
3
4
  import { foldSearchText, searchWords } from "./DropdownHelpers.js";
4
- import { emptyInputValue, inputFieldName, inputValuesEqual, isEmptyInputValue } from "./InputParamsHelpers.js";
5
+ import { emptyInputValue, inputFieldName, inputTypeName, inputValuesEqual, isEmptyInputValue } from "./InputParamsHelpers.js";
5
6
  import { columnValue, readPath, searchTextOf } from "./TableHelpers.js";
6
7
  /*
7
8
  * A data table's advanced filter, with no React: input params as filters, the values that filter, a client table's
8
9
  * test of a row, and the names a server takes.
9
10
  */
10
11
  // Inputs whose value cannot filter a row, and names a Nexus page request already uses for its paging.
11
- const UNSUPPORTED = new Set(["markdown", "rich-text", "tree", "tabular", "group", "password"]);
12
+ const UNSUPPORTED = new Set([EInputTypes.Markdown, EInputTypes.RichText, EInputTypes.Tree, EInputTypes.Tabular, EInputTypes.Group, EInputTypes.Password, EInputTypes.Picture]);
12
13
  const RESERVED = new Set(["searchKeyword", "skipCount", "maxCount", "sorting", "sortType"]);
13
14
  const warned = new Set();
14
15
  function filterable(params) {
@@ -17,7 +18,7 @@ function filterable(params) {
17
18
  return true;
18
19
  if (!warned.has(params.name)) {
19
20
  warned.add(params.name);
20
- console.warn(`DataTable: the filter "${params.name}" is ignored: ${reserved ? "a page request already sends that name" : `a ${params.type} input cannot filter rows`}.`);
21
+ console.warn(`DataTable: the filter "${params.name}" is ignored: ${reserved ? "a page request already sends that name" : `a ${inputTypeName(params.type)} input cannot filter rows`}.`);
21
22
  }
22
23
  return false;
23
24
  }
@@ -62,12 +63,12 @@ function cellTest(params, value, column, bound) {
62
63
  // A moment's day is the one the table shows it on; any other value is read as an ISO date.
63
64
  const dayOf = (cell) => (column?.format === "date-time" ? toLocalDateTime(cell)?.date : String(cell).slice(0, 10));
64
65
  switch (params.type) {
65
- case "text":
66
- case "email":
67
- case "search":
68
- case "tel":
69
- case "url":
70
- case "textarea": {
66
+ case EInputTypes.Text:
67
+ case EInputTypes.Email:
68
+ case EInputTypes.Search:
69
+ case EInputTypes.Tel:
70
+ case EInputTypes.Url:
71
+ case EInputTypes.TextArea: {
71
72
  // Every word, folded as the search folds it, in the text the cell shows (an option's label, Yes or No).
72
73
  const words = searchWords(String(value));
73
74
  return (cell, row) => {
@@ -75,31 +76,31 @@ function cellTest(params, value, column, bound) {
75
76
  return words.every(word => text.includes(word));
76
77
  };
77
78
  }
78
- case "checkbox":
79
- case "switch":
80
- case "radio":
79
+ case EInputTypes.CheckBox:
80
+ case EInputTypes.Switch:
81
+ case EInputTypes.Radio:
81
82
  return cell => cell === true || cell === "true" || cell === 1;
82
- case "number":
83
- case "slider": {
83
+ case EInputTypes.Number:
84
+ case EInputTypes.Slider: {
84
85
  const number = Number(value);
85
86
  return cell => (bound === "From" ? Number(cell) >= number : bound === "To" ? Number(cell) <= number : Number(cell) === number);
86
87
  }
87
- case "range-slider": {
88
+ case EInputTypes.RangeSlider: {
88
89
  const [low, high] = value;
89
90
  return cell => Number(cell) >= low && Number(cell) <= high;
90
91
  }
91
- case "date":
92
+ case EInputTypes.Date:
92
93
  return cell => dayOf(cell) === value;
93
- case "date-range": {
94
+ case EInputTypes.DateRange: {
94
95
  const [from, to] = value;
95
96
  return cell => {
96
97
  const day = dayOf(cell);
97
98
  return day !== undefined && (!from || day >= from) && (!to || day <= to);
98
99
  };
99
100
  }
100
- case "time":
101
+ case EInputTypes.Time:
101
102
  return cell => String(cell).slice(0, 5) === String(value).slice(0, 5);
102
- case "date-time": {
103
+ case EInputTypes.DateTime: {
103
104
  // The same minute, however each side writes the moment ("…Z", "+05:45", or no zone for UTC).
104
105
  const minute = (moment) => toUtcDateTime(moment)?.slice(0, 16);
105
106
  const wanted = minute(value);
@@ -121,7 +122,7 @@ export function filterMatcher(columns, inputs, filters) {
121
122
  const tests = activeInputs(inputs, filters).map(([params, value]) => {
122
123
  const { name } = params;
123
124
  let column = columns.find(candidate => candidate.key === name);
124
- const [, key, bound] = (!column && params.type === "number" && /^(.+)(From|To)$/.exec(name)) || [];
125
+ const [, key, bound] = (!column && params.type === EInputTypes.Number && /^(.+)(From|To)$/.exec(name)) || [];
125
126
  column ??= columns.find(candidate => candidate.key === key);
126
127
  const test = cellTest(params, value, column, column ? bound : undefined);
127
128
  return (row) => {
@@ -149,11 +150,11 @@ export function filterRequest(filters, inputs, columns, timeZone) {
149
150
  if (to)
150
151
  request[`${name}Before`] = moments ? fromLocalDateTime(addDays(to, 1), "00:00", timeZone) : addDays(to, 1);
151
152
  };
152
- if (params.type === "date-range")
153
+ if (params.type === EInputTypes.DateRange)
153
154
  days(...value);
154
- else if (params.type === "date" && moments)
155
+ else if (params.type === EInputTypes.Date && moments)
155
156
  days(value, value);
156
- else if (params.type === "range-slider")
157
+ else if (params.type === EInputTypes.RangeSlider)
157
158
  [request[`${name}From`], request[`${name}To`]] = value;
158
159
  else
159
160
  request[name] = value;
@@ -26,12 +26,17 @@ export interface CellTextOptions {
26
26
  timeZone?: string;
27
27
  /** How numbers are written: a locale, or the person's settings (`numberLocale`, `useNumberLocale`). Default `en-US`. */
28
28
  locale?: string;
29
+ /** The currency of a `money` column without its own (`DataTableProps.currency`): "Rs.". */
30
+ currency?: string | null;
29
31
  }
30
32
  /**
31
33
  * A value as the cell writes it, in the column's format: "1,250.00", "Rs. 4,500.00", "12.5%", the date and time in the
32
- * user's settings, "Yes" or "No", an option's label. Empty values give "".
34
+ * user's settings, "Yes" or "No", an option's label. Empty values give "". `row` is the record, for a currency the
35
+ * column reads from it (`currency: row => row.currencyText`).
33
36
  */
34
- export declare function formatCellText<TRow extends TableRow>(column: TableColumn<TRow>, value: unknown, options?: CellTextOptions): string;
37
+ export declare function formatCellText<TRow extends TableRow>(column: TableColumn<TRow>, value: unknown, options?: CellTextOptions, row?: TRow): string;
38
+ /** A money column's currency: its own (a text, or read from the row), else the table's; "" for none. */
39
+ export declare function columnCurrency<TRow extends TableRow>(column: TableColumn<TRow>, options?: CellTextOptions, row?: TRow): string;
35
40
  /** Text the search looks in: the column's `text`, an option's label, or the value as text (dates as ISO text). */
36
41
  export declare function searchTextOf<TRow extends TableRow>(column: TableColumn<TRow>, row: TRow): string;
37
42
  /** Compares two values of a column for sorting: numbers as numbers, text as a person reads it ("Room 9" before "Room 10"). */
@@ -95,7 +100,23 @@ export interface ViewColumnsOptions extends ColumnRights {
95
100
  widths?: Readonly<Record<string, number>>;
96
101
  /** Pixels from the pinned side, measured from the columns before it. */
97
102
  offsets?: Readonly<Record<string, number>>;
103
+ /** Pinned columns that scroll for now, the table being too narrow for them (`releasedPins`): their `pin` is null. */
104
+ released?: ReadonlySet<string>;
98
105
  }
106
+ /**
107
+ * The most of a table's width on screen that the columns staying in place may take: the numbers, the checkboxes and the
108
+ * actions, which always stay, and the pinned columns. Past it the pinned ones give way (`releasedPins`), so a phone
109
+ * keeps at least 40% of the table for the columns that scroll.
110
+ */
111
+ export declare const PINNED_SHARE = 0.6;
112
+ /**
113
+ * Which pinned columns scroll for now, the table being narrow: the pinned ones stay from the outer edge in - the left
114
+ * side first - while they and the columns that always stay (`frame`: the numbers and checkboxes, the actions; their
115
+ * width in pixels) take at most `share` of `view`, the width on screen; every one past that scrolls with the rest,
116
+ * and so do the ones inside it on the same side. `left` and `right`: each side's pinned columns with their widths,
117
+ * from the outer edge in. A table not laid out (`view` 0, in a closed tab) releases none.
118
+ */
119
+ export declare function releasedPins(view: number, frame: number, left: readonly (readonly [string, number])[], right: readonly (readonly [string, number])[], share?: number): Set<string>;
99
120
  /**
100
121
  * The columns as the table draws them: in the user's order, with the pinned ones moved to their side (keeping their
101
122
  * order among themselves), each with its width, its distance from that edge, and what the user may do with it.