@ditojs/admin 3.2.7 → 3.3.1

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 (112) hide show
  1. package/dist/dito-admin.css +1 -1
  2. package/dist/dito-admin.es.js +6011 -5826
  3. package/dist/dito-admin.umd.js +3 -7
  4. package/package.json +18 -17
  5. package/src/DitoAdmin.js +44 -24
  6. package/src/DitoContext.js +28 -2
  7. package/src/DitoTypeComponent.js +1 -0
  8. package/src/appState.js +6 -3
  9. package/src/components/DitoAccount.vue +35 -27
  10. package/src/components/DitoAffixes.vue +9 -2
  11. package/src/components/DitoClipboard.vue +59 -93
  12. package/src/components/DitoConfirmMessage.vue +18 -0
  13. package/src/components/DitoContainer.vue +56 -18
  14. package/src/components/DitoCreateButton.vue +36 -84
  15. package/src/components/DitoDialog.vue +20 -15
  16. package/src/components/DitoDragHandle.vue +44 -0
  17. package/src/components/DitoDraggable.vue +43 -16
  18. package/src/components/DitoEditButtons.vue +48 -24
  19. package/src/components/DitoErrors.vue +9 -56
  20. package/src/components/DitoForm.vue +82 -42
  21. package/src/components/DitoInputAffixes.vue +50 -0
  22. package/src/components/DitoLabel.vue +1 -15
  23. package/src/components/DitoMenu.vue +45 -52
  24. package/src/components/DitoNotifications.vue +38 -14
  25. package/src/components/DitoOptionList.vue +81 -0
  26. package/src/components/DitoOptionsEditButtons.vue +57 -0
  27. package/src/components/DitoPagination.vue +1 -8
  28. package/src/components/DitoPane.vue +51 -39
  29. package/src/components/DitoPanel.vue +12 -26
  30. package/src/components/DitoPanels.vue +11 -5
  31. package/src/components/DitoResizeHandle.vue +47 -0
  32. package/src/components/DitoRoot.vue +113 -320
  33. package/src/components/DitoSchema.vue +72 -63
  34. package/src/components/DitoSchemaInlined.vue +8 -9
  35. package/src/components/DitoScopes.vue +14 -18
  36. package/src/components/DitoSidebar.vue +4 -29
  37. package/src/components/DitoSourceItem.vue +114 -0
  38. package/src/components/DitoTableCell.vue +4 -0
  39. package/src/components/DitoTableHead.vue +15 -17
  40. package/src/components/DitoTabs.vue +25 -0
  41. package/src/components/DitoTrail.vue +25 -25
  42. package/src/components/DitoTreeItem.vue +102 -141
  43. package/src/components/DitoUploadFile.vue +5 -2
  44. package/src/components/DitoView.vue +33 -24
  45. package/src/index.js +4 -1
  46. package/src/mixins/DitoMixin.js +77 -110
  47. package/src/mixins/DomMixin.js +12 -14
  48. package/src/mixins/EmitterMixin.js +3 -2
  49. package/src/mixins/ItemMixin.js +38 -94
  50. package/src/mixins/OptionsMixin.js +38 -25
  51. package/src/mixins/ResourceMixin.js +182 -111
  52. package/src/mixins/RouteMixin.js +61 -44
  53. package/src/mixins/SchemaParentMixin.js +4 -1
  54. package/src/mixins/SourceMixin.js +118 -100
  55. package/src/mixins/TypeMixin.js +42 -40
  56. package/src/mixins/ValidationMixin.js +16 -4
  57. package/src/mixins/ValidatorMixin.js +1 -1
  58. package/src/styles/_settings.scss +4 -0
  59. package/src/styles/_tippy.scss +0 -11
  60. package/src/styles/style.scss +0 -1
  61. package/src/types/DitoTypeButton.vue +11 -14
  62. package/src/types/DitoTypeCheckboxes.vue +35 -17
  63. package/src/types/DitoTypeCode.vue +19 -41
  64. package/src/types/DitoTypeColor.vue +67 -19
  65. package/src/types/DitoTypeDate.vue +11 -14
  66. package/src/types/DitoTypeList.vue +50 -62
  67. package/src/types/DitoTypeMarkup.vue +41 -54
  68. package/src/types/DitoTypeMultiselect.vue +82 -52
  69. package/src/types/DitoTypeNumber.vue +12 -15
  70. package/src/types/DitoTypeObject.vue +4 -38
  71. package/src/types/DitoTypeRadio.vue +19 -6
  72. package/src/types/DitoTypeSection.vue +23 -9
  73. package/src/types/DitoTypeSelect.vue +39 -23
  74. package/src/types/DitoTypeSlider.vue +9 -1
  75. package/src/types/DitoTypeSwitch.vue +1 -1
  76. package/src/types/DitoTypeText.vue +11 -14
  77. package/src/types/DitoTypeTreeList.vue +46 -8
  78. package/src/types/DitoTypeUpload.vue +190 -67
  79. package/src/utils/DataModel.js +368 -146
  80. package/src/utils/FileDropTargets.js +97 -0
  81. package/src/utils/ItemStores.js +31 -0
  82. package/src/utils/ListQuery.js +115 -0
  83. package/src/utils/LoadingTracker.js +76 -0
  84. package/src/utils/SchemaGraph.js +3 -2
  85. package/src/utils/Session.js +151 -0
  86. package/src/utils/ViewRegistry.js +85 -0
  87. package/src/utils/accessor.js +12 -4
  88. package/src/utils/affixes.js +33 -0
  89. package/src/utils/changeOnceEdited.js +33 -0
  90. package/src/utils/clipboard.js +55 -0
  91. package/src/utils/data.js +10 -5
  92. package/src/utils/dialogs.js +59 -0
  93. package/src/utils/dragResize.js +61 -0
  94. package/src/utils/filter.js +36 -2
  95. package/src/utils/html.js +9 -0
  96. package/src/utils/list.js +10 -0
  97. package/src/utils/options.js +4 -0
  98. package/src/utils/registration.js +18 -0
  99. package/src/utils/request.js +108 -13
  100. package/src/utils/route.js +6 -17
  101. package/src/utils/schema/data.js +24 -2
  102. package/src/utils/schema/events.js +19 -0
  103. package/src/utils/schema/item.js +114 -0
  104. package/src/utils/schema/lookup.js +39 -14
  105. package/src/utils/schema/setup.js +5 -5
  106. package/src/utils/schema/validation.js +16 -2
  107. package/src/utils/uid.js +105 -8
  108. package/src/validators/_decimals.js +16 -5
  109. package/types/index.d.ts +168 -41
  110. package/src/mixins/LoadingMixin.js +0 -23
  111. package/src/mixins/PulldownMixin.js +0 -72
  112. package/src/styles/_pulldown.scss +0 -38
@@ -1,4 +1,11 @@
1
- import { effectScope, computed, watch, nextTick, shallowRef } from 'vue'
1
+ import {
2
+ effectScope,
3
+ computed,
4
+ watch,
5
+ nextTick,
6
+ shallowRef,
7
+ toRaw
8
+ } from 'vue'
2
9
  import {
3
10
  isArray,
4
11
  isPlainObject,
@@ -9,7 +16,11 @@ import {
9
16
  } from '@ditojs/utils'
10
17
  import DitoContext from '../DitoContext.js'
11
18
  import { DataSchemaResolver } from './DataSchemaResolver.js'
12
- import { getParentDataPath } from './data.js'
19
+ import {
20
+ appendDataPath,
21
+ getParentDataPath,
22
+ getRelativeDataPath
23
+ } from './data.js'
13
24
  import { isNested } from './schema/structure.js'
14
25
  import { isEmptySchema } from './schema/lookup.js'
15
26
  import {
@@ -42,7 +53,7 @@ import {
42
53
  // displays them, see `getOptions()`. Both get the same option objects, and
43
54
  // computes that read options only run once they are loaded.
44
55
  // - Submitting waits for the loads of data schemas and options that are still
45
- // pending, see `waitUntilSettled()`.
56
+ // pending, for a limited time, see `waitUntilSettled()`.
46
57
  // - The data is dirty when its processed data differs from a snapshot taken
47
58
  // when the data was set up, saved or applied, see `isDirty`. The values that
48
59
  // the model derives until it settled, e.g. from loaded options, don't count,
@@ -62,12 +73,12 @@ import {
62
73
  // when present. The model needs to be stopped before the component unmounts.
63
74
 
64
75
  export class DataModel {
65
- // The entries of the components with computed values and the scopes of the
66
- // watchers that write these values into the data, by component path:
67
- computedValueRecords = new Map()
68
- // The entries of the components whose options were read and the resolvers
69
- // of their options, by component path, see `getOptionsResolver()`:
70
- optionsRecords = new Map()
76
+ // The records of the components with computed values or options, by their
77
+ // data and name, so that they're kept when list items move, see
78
+ // `getComponentRecord()`. Records of components that the walk of the data
79
+ // doesn't visit anymore, e.g. of removed items or hidden components, are
80
+ // removed, see `updateComponentRecords()`:
81
+ componentRecords = new EntryMap()
71
82
  // The promises of the loads of data schemas and options that are pending,
72
83
  // see `waitUntilSettled()`:
73
84
  pendingLoads = new Set()
@@ -78,6 +89,9 @@ export class DataModel {
78
89
  // the snapshot is taken, which `isDirty` takes from the current data, `null`
79
90
  // once it settled, see `takeProcessedDataSnapshot()`:
80
91
  derivedValueDataPaths = null
92
+ // The sets of data paths of the values that the model writes while clean
93
+ // changes settle, one per pending `applyCleanChanges()`:
94
+ cleanChangesDerivedValueDataPaths = new Set()
81
95
  // The number of writes of computed values per data path in the current
82
96
  // microtask, see `isWriteLoop()`:
83
97
  computedValueWriteCounts = null
@@ -99,12 +113,12 @@ export class DataModel {
99
113
  modelSetupWatchOptions
100
114
  )
101
115
  // The entries are read through a computed property, so that the watchers
102
- // of the entries can check synchronously whether their entry is still
116
+ // of the records can check synchronously whether their record is still
103
117
  // current, see `createComputedValueScope()`.
104
118
  this.dataEntries = computed(() => this.getDataEntries())
105
119
  watch(
106
- () => this.dataEntries.value.computedValueEntries,
107
- entries => this.updateComputedValueRecords(entries),
120
+ () => this.dataEntries.value.recordedEntries,
121
+ entries => this.updateComponentRecords(entries),
108
122
  modelSetupWatchOptions
109
123
  )
110
124
  // Values that go missing after the data was set up, e.g. in items that
@@ -165,8 +179,7 @@ export class DataModel {
165
179
 
166
180
  stop() {
167
181
  this.rootScope.stop()
168
- this.computedValueRecords.clear()
169
- this.optionsRecords.clear()
182
+ this.componentRecords.clear()
170
183
  this.pendingLoads.clear()
171
184
  }
172
185
 
@@ -180,10 +193,41 @@ export class DataModel {
180
193
  // ones that they cause, e.g. options that depend on computed values that
181
194
  // depend on loaded options, and for the watchers to write the resulting
182
195
  // computed values into the data.
183
- async waitUntilSettled() {
184
- while (this.hasPendingLoads) {
185
- await Promise.all(this.pendingLoads)
186
- await nextTick()
196
+ // Loads that never finish, e.g. hung requests, would block callers like
197
+ // `submit()` forever. After `timeout` milliseconds, the wait gives up with a
198
+ // warning and resolves, as the server validates the data anyway, while the
199
+ // loads keep running. `timeout: null` waits as long as it takes, for the
200
+ // model's own bookkeeping, which nothing waits for.
201
+ async waitUntilSettled({ timeout = settleTimeout } = {}) {
202
+ let timeoutId = null
203
+ const timedOut =
204
+ timeout != null
205
+ ? new Promise(resolve => {
206
+ timeoutId = setTimeout(() => resolve(true), timeout)
207
+ })
208
+ : null
209
+ const settled = (async () => {
210
+ while (this.hasPendingLoads) {
211
+ await Promise.all(this.pendingLoads)
212
+ await nextTick()
213
+ }
214
+ return false
215
+ })()
216
+ try {
217
+ const hasTimedOut = await (
218
+ timedOut
219
+ ? Promise.race([settled, timedOut])
220
+ : settled
221
+ )
222
+ if (hasTimedOut) {
223
+ console.warn(
224
+ `The data model didn't settle within ${timeout}ms, as ` +
225
+ `${this.pendingLoads.size} loads of data schemas or options are ` +
226
+ `still pending. Continuing without them.`
227
+ )
228
+ }
229
+ } finally {
230
+ clearTimeout(timeoutId)
187
231
  }
188
232
  }
189
233
 
@@ -210,7 +254,7 @@ export class DataModel {
210
254
  }
211
255
  // Let the watchers and the rendering start their loads first:
212
256
  await nextTick()
213
- await this.waitUntilSettled()
257
+ await this.waitUntilSettled({ timeout: null })
214
258
  // Snapshots that were taken since settle by themselves.
215
259
  const isSnapshotReplaced = (
216
260
  this.derivedValueDataPaths !==
@@ -259,10 +303,14 @@ export class DataModel {
259
303
 
260
304
  // Makes clean changes to the data, which don't make it dirty, while other
261
305
  // changes still do, e.g. to apply what an action on the server already
262
- // saved: Once the model settled, the values that changed in the processed
263
- // data, including the values derived from the changes, are taken over into
264
- // the snapshot. Arrays whose length changes are taken over as a whole, see
265
- // `takeOverChangedValues()`. `makeChanges()` is called synchronously.
306
+ // saved: The values that `makeChanges()` changes in the processed data are
307
+ // compared right after it is called synchronously, so that changes made
308
+ // while the model settles, e.g. by the user, aren't included. Once the model
309
+ // settled, they are taken over into the snapshot, along with the values that
310
+ // the model writes in the meantime, e.g. derived from the changes. Like in
311
+ // `takeProcessedDataSnapshot()`, these include the values derived from
312
+ // other changes. Arrays whose length changes are taken over as a whole, see
313
+ // `takeOverChangedValues()`.
266
314
  async applyCleanChanges(makeChanges) {
267
315
  const data = this.getData()
268
316
  const hasSnapshotOfData = this.processedDataSnapshot.value?.data === data
@@ -273,25 +321,49 @@ export class DataModel {
273
321
  : null
274
322
  makeChanges()
275
323
  if (hasSnapshotOfData) {
276
- // Let the watchers write the values derived from the changes first:
277
- await nextTick()
278
- await this.waitUntilSettled()
324
+ const processedDataAfterChanges = clone(
325
+ this.getProcessedDataForDirtyCheck()
326
+ )
327
+ const derivedValueDataPaths = new Set()
328
+ this.cleanChangesDerivedValueDataPaths.add(derivedValueDataPaths)
329
+ try {
330
+ // Let the watchers write the values derived from the changes first:
331
+ await nextTick()
332
+ await this.waitUntilSettled({ timeout: null })
333
+ } finally {
334
+ this.cleanChangesDerivedValueDataPaths.delete(derivedValueDataPaths)
335
+ }
279
336
  // Take the changes over into the current snapshot of the data, which may
280
337
  // have been replaced in the meantime, e.g. once the model settled.
281
338
  const processedDataSnapshot = this.processedDataSnapshot.value
282
339
  if (processedDataSnapshot?.data === data && this.rootScope.active) {
283
340
  this.processedDataSnapshot.value = {
284
341
  data,
285
- processedData: takeOverChangedValues(
286
- processedDataSnapshot.processedData,
287
- processedDataBeforeChanges,
288
- this.getProcessedDataForDirtyCheck()
342
+ processedData: takeOverValuesAtDataPaths(
343
+ takeOverChangedValues(
344
+ processedDataSnapshot.processedData,
345
+ processedDataBeforeChanges,
346
+ processedDataAfterChanges
347
+ ),
348
+ this.getProcessedDataForDirtyCheck(),
349
+ derivedValueDataPaths
289
350
  )
290
351
  }
291
352
  }
292
353
  }
293
354
  }
294
355
 
356
+ // Records the data path of a computed value that the model writes, as
357
+ // derived while it settles, see `takeProcessedDataSnapshot()` and
358
+ // `applyCleanChanges()`. Defaults don't need to be recorded, as the processed
359
+ // data holds the defaults of missing values already, see `processData()`.
360
+ recordDerivedValueDataPath(dataPath) {
361
+ this.derivedValueDataPaths?.add(dataPath)
362
+ for (const dataPaths of this.cleanChangesDerivedValueDataPaths) {
363
+ dataPaths.add(dataPath)
364
+ }
365
+ }
366
+
295
367
  // Tracks the promise of a pending load until it settles, so that
296
368
  // `waitUntilSettled()` waits for it.
297
369
  trackPendingLoad(promise) {
@@ -316,24 +388,24 @@ export class DataModel {
316
388
 
317
389
  // Walks the data and returns the entries of `processSchemaData()` of the
318
390
  // components whose `if` doesn't evaluate to `false`: the ones with computed
319
- // values, by component path, and the ones whose values are missing. Called
320
- // by a computed property, so that it runs again when the data structure
321
- // changes, e.g. when list items are added or removed, values go missing, or
322
- // `if` conditions change.
391
+ // values or options, by data and name, see `componentRecords`, and the ones
392
+ // whose values are missing. Called by a computed property, so that it runs
393
+ // again when the data structure changes, e.g. when list items are added,
394
+ // moved or removed, values go missing, or `if` conditions change.
323
395
  getDataEntries() {
324
396
  const schema = this.getSchema()
325
397
  const data = this.getData()
326
- const computedValueEntries = new Map()
398
+ const recordedEntries = new EntryMap()
327
399
  const entriesWithMissingValues = []
328
400
  if (data && !isEmptySchema(schema)) {
329
401
  processSchemaData(schema, data, {
330
402
  dataPath: this.dataPath,
331
403
  componentPath: this.componentPath,
332
- shouldProcess: entry => this.isEntryShown(entry),
404
+ shouldProcess: entry => this.shouldRenderEntry(entry),
333
405
  shouldSkipSourcesWithResource: true,
334
406
  before: entry => {
335
- if (hasComputedValueSource(entry.schema)) {
336
- computedValueEntries.set(entry.componentPath, entry)
407
+ if (hasComputedValueSource(entry.schema) || entry.schema.options) {
408
+ recordedEntries.set(entry, entry)
337
409
  }
338
410
  if (this.shouldSetDefaultValue(entry)) {
339
411
  entriesWithMissingValues.push(entry)
@@ -342,7 +414,7 @@ export class DataModel {
342
414
  options: { component: this.component, rootData: this.rootData }
343
415
  })
344
416
  }
345
- return { computedValueEntries, entriesWithMissingValues }
417
+ return { recordedEntries, entriesWithMissingValues }
346
418
  }
347
419
 
348
420
  // Returns whether the `if` of the entry's component doesn't evaluate to
@@ -351,7 +423,7 @@ export class DataModel {
351
423
  // `getDataEntries()`, which leads to the same entries.
352
424
  // An `if` that fails only affects its own component, which is treated as
353
425
  // shown, not the walk of all others, which would stop computing them.
354
- isEntryShown(entry) {
426
+ shouldRenderEntry(entry) {
355
427
  if (entry.schema.if === undefined) {
356
428
  return true
357
429
  }
@@ -395,68 +467,87 @@ export class DataModel {
395
467
  }
396
468
  }
397
469
 
398
- // Keeps the records of the components that are still present with the same
399
- // schema and data, creates records with new scopes for new ones, and stops
400
- // the scopes of the others.
401
- updateComputedValueRecords(entries) {
402
- const previousRecords = this.computedValueRecords
403
- this.computedValueRecords = new Map()
404
- for (const [componentPath, entry] of entries) {
405
- let computedValueRecord = previousRecords.get(componentPath)
406
- if (
407
- computedValueRecord?.entry.schema === entry.schema &&
408
- computedValueRecord.entry.data === entry.data
409
- ) {
410
- previousRecords.delete(componentPath)
411
- } else {
412
- computedValueRecord = {
413
- entry,
414
- scope: this.createComputedValueScope(entry)
415
- }
470
+ // Keeps the records of the components that are still present, with their
471
+ // current entries, e.g. with the data paths of items that moved, creates
472
+ // records for new ones, and removes the others, stopping their scopes. The
473
+ // scopes of records whose schema changed are replaced.
474
+ updateComponentRecords(entries) {
475
+ const previousRecords = this.componentRecords
476
+ this.componentRecords = new EntryMap()
477
+ for (const entry of entries.values()) {
478
+ const record = previousRecords.get(entry) ?? createComponentRecord(entry)
479
+ previousRecords.delete(entry)
480
+ if (record.entry.schema !== entry.schema) {
481
+ record.computedValueScope?.stop()
482
+ record.computedValueScope = null
416
483
  }
417
- this.computedValueRecords.set(componentPath, computedValueRecord)
484
+ record.entry = entry
485
+ record.dataPath.value = entry.dataPath
486
+ // Add the record before its scope is created, as its watchers may read
487
+ // its options right away, see `getOptionsResolver()`.
488
+ this.componentRecords.set(entry, record)
489
+ if (hasComputedValueSource(entry.schema)) {
490
+ record.computedValueScope ??= this.createComputedValueScope(record)
491
+ }
492
+ }
493
+ for (const { computedValueScope } of previousRecords.values()) {
494
+ computedValueScope?.stop()
418
495
  }
419
- for (const { scope } of previousRecords.values()) {
420
- scope.stop()
496
+ }
497
+
498
+ // Returns the record of the component of the entry, see `componentRecords`,
499
+ // and creates it if the walk of the data didn't, e.g. for components that
500
+ // read their options before it visits them.
501
+ getComponentRecord(entry) {
502
+ let record = this.componentRecords.get(entry)
503
+ if (!record) {
504
+ record = createComponentRecord(entry)
505
+ this.componentRecords.set(entry, record)
421
506
  }
507
+ return record
422
508
  }
423
509
 
424
510
  // Creates the scope of the watchers that write the computed values of the
425
- // entry into the data. Entries stop being current before their scope is
426
- // stopped, as watchers that don't belong to components run in the order in
427
- // which they're triggered, e.g. when list items are removed or `if`
428
- // conditions change. The watchers skip these entries, see `isEntryCurrent()`.
429
- createComputedValueScope(entry) {
511
+ // record's component into the data, with its current entry, so that the
512
+ // values of items that move are only computed again if their computes read
513
+ // their position, see `getComputedValueResult()`. Records stop being
514
+ // current before their scope is stopped, as watchers that don't belong to
515
+ // components run in the order in which they're triggered, e.g. when list
516
+ // items are removed or `if` conditions change. The watchers skip these
517
+ // records, see `isRecordCurrent()`.
518
+ createComputedValueScope(record) {
519
+ const { schema } = record.entry
430
520
  const scope = this.rootScope.run(() => effectScope())
431
521
  scope.run(() => {
432
- const isEntryCurrent = computed(() => this.isEntryCurrent(entry))
433
- if (entry.schema.compute) {
522
+ const isRecordCurrent = computed(() =>
523
+ this.isRecordCurrent(record, schema)
524
+ )
525
+ if (schema.compute) {
434
526
  watch(
435
527
  // Return a new object each time, so that the value is also written
436
528
  // when only the value in the data changed, e.g. through user input.
437
529
  () =>
438
- isEntryCurrent.value ? this.getComputedValueResult(entry) : null,
530
+ isRecordCurrent.value
531
+ ? this.getComputedValueResult(record)
532
+ : null,
439
533
  computedResult => {
440
534
  if (computedResult) {
441
- this.writeComputedValue(entry, computedResult.value)
535
+ this.writeComputedValue(record.entry, computedResult.value)
442
536
  }
443
537
  },
444
538
  modelWatchOptions
445
539
  )
446
540
  }
447
- if (hasValueFromDataSchema(entry.schema)) {
448
- const dataSchemaResolver = this.createDataSchemaResolver(
449
- entry.schema,
450
- entry
451
- )
541
+ if (hasValueFromDataSchema(schema)) {
542
+ const dataSchemaResolver = this.createDataSchemaResolver(schema, record)
452
543
  watch(
453
544
  () =>
454
- isEntryCurrent.value && !dataSchemaResolver.isLoading
545
+ isRecordCurrent.value && !dataSchemaResolver.isLoading
455
546
  ? { value: dataSchemaResolver.value }
456
547
  : null,
457
548
  resolved => {
458
549
  if (resolved) {
459
- this.writeComputedValue(entry, resolved.value)
550
+ this.writeComputedValue(record.entry, resolved.value)
460
551
  }
461
552
  },
462
553
  modelWatchOptions
@@ -466,18 +557,21 @@ export class DataModel {
466
557
  return scope
467
558
  }
468
559
 
469
- // Returns whether the model computes the value of the component at the
470
- // component path, i.e. whether its walk of the data visits the component.
471
- hasComputedValueEntry(componentPath) {
472
- return this.dataEntries.value.computedValueEntries.has(componentPath)
560
+ // Returns whether the model computes the value of the component with the
561
+ // data and name of the entry, i.e. whether its walk of the data visits the
562
+ // component.
563
+ hasComputedValueEntry(entry) {
564
+ const currentEntry = this.dataEntries.value.recordedEntries.get(entry)
565
+ return !!currentEntry && hasComputedValueSource(currentEntry.schema)
473
566
  }
474
567
 
475
- // Returns whether the entry is still one of the current entries, with the
476
- // same schema and data, see `getDataEntries()`.
477
- isEntryCurrent({ componentPath, schema, data }) {
478
- const currentEntry =
479
- this.dataEntries.value.computedValueEntries.get(componentPath)
480
- return currentEntry?.schema === schema && currentEntry.data === data
568
+ // Returns whether the record's component is still one of the current
569
+ // entries, with the same schema, see `getDataEntries()`.
570
+ isRecordCurrent(record, schema) {
571
+ const currentEntry = this.dataEntries.value.recordedEntries.get(
572
+ record.entry
573
+ )
574
+ return currentEntry?.schema === schema
481
575
  }
482
576
 
483
577
  // Calls `schema.compute()` with the main schema component of the data, like
@@ -487,14 +581,24 @@ export class DataModel {
487
581
  // loaded, which aborts it, so that the current value is kept until they are
488
582
  // and `compute()` can rely on them. The watcher that calls it depends on the
489
583
  // options through reading them, and calls it again once they're loaded.
490
- getComputedValueResult(entry) {
491
- const { schema, data, name, dataPath } = entry
584
+ // Computes the value of the record's component. Its data path is read
585
+ // through the record, so that only computes that read their position are
586
+ // computed again when their items move, see `updateComponentRecords()`.
587
+ getComputedValueResult(record) {
588
+ const { entry } = record
589
+ const { schema, data, name } = entry
492
590
  try {
493
- const value = computeValue(schema, data, name, dataPath, {
494
- component: this.component.mainSchemaComponent ?? this.component,
495
- rootData: this.rootData,
496
- getOptions: schema.options ? () => this.getLoadedOptions(entry) : null
497
- })
591
+ const value = computeValue(
592
+ schema,
593
+ data,
594
+ name,
595
+ () => record.dataPath.value,
596
+ {
597
+ component: this.component.mainSchemaComponent ?? this.component,
598
+ rootData: this.rootData,
599
+ getOptions: schema.options ? () => this.getLoadedOptions(entry) : null
600
+ }
601
+ )
498
602
  return { value }
499
603
  } catch (error) {
500
604
  if (error === optionsNotLoaded) {
@@ -505,55 +609,56 @@ export class DataModel {
505
609
  }
506
610
 
507
611
  // Returns the options of the component of the entry, or aborts the
508
- // `compute()` that reads them, while they aren't loaded, see
509
- // `getComputedValueResult()`.
612
+ // `compute()` that reads them, while they're loading, see
613
+ // `getComputedValueResult()`. Options that resolve to `undefined` aren't
614
+ // loading, e.g. when their load failed, which logs the error, or when their
615
+ // data path points to a missing value: `compute()` sees them as `undefined`,
616
+ // and falls back on `options?.…` like components do, see `OptionsMixin`.
510
617
  getLoadedOptions(entry) {
511
- const options = this.getOptions(entry)
512
- if (options === undefined) {
618
+ const resolver = this.getOptionsResolver(entry)
619
+ if (resolver.isLoading) {
513
620
  throw optionsNotLoaded
514
621
  }
515
- return options
622
+ return resolver.value
516
623
  }
517
624
 
518
625
  // Returns the options of the component of the entry, resolved from
519
- // `schema.options` when they're first read, `undefined` while loading.
626
+ // `schema.options` when they're first read, `undefined` while loading, see
627
+ // `getLoadedOptions()`.
520
628
  getOptions(entry) {
521
629
  return this.getOptionsResolver(entry).value
522
630
  }
523
631
 
524
632
  // Returns the resolver of the options of the component of the entry, shared
525
- // by all callers with the same component path, schema and data, so that
526
- // computes and the component that displays the options get the same option
527
- // objects, and the options only load once.
633
+ // by all callers with the same data, name and options, so that computes and
634
+ // the component that displays the options get the same option objects, and
635
+ // the options only load once, also when list items move. Components that
636
+ // keep displaying options of records that were removed keep their own
637
+ // resolvers, see `OptionsMixin`.
528
638
  getOptionsResolver(entry) {
529
- const { schema, data, componentPath } = entry
530
- let optionsRecord = this.optionsRecords.get(componentPath)
531
- if (
532
- !optionsRecord ||
533
- optionsRecord.entry.schema.options !== schema.options ||
534
- optionsRecord.entry.data !== data
535
- ) {
536
- optionsRecord = {
537
- entry,
538
- resolver: this.createDataSchemaResolver(schema.options, entry)
539
- }
540
- this.optionsRecords.set(componentPath, optionsRecord)
639
+ const record = this.getComponentRecord(entry)
640
+ const { options } = entry.schema
641
+ if (!record.optionsResolver || record.optionsSchema !== options) {
642
+ record.optionsSchema = options
643
+ record.optionsResolver = this.createDataSchemaResolver(options, record)
541
644
  }
542
- return optionsRecord.resolver
645
+ return record.optionsResolver
543
646
  }
544
647
 
545
- createDataSchemaResolver(dataSchema, entry) {
648
+ // Creates the resolver of a data schema of the record's component, with the
649
+ // context of its current entry.
650
+ createDataSchemaResolver(dataSchema, record) {
546
651
  return new DataSchemaResolver(dataSchema, {
547
- createContext: () => this.createEntryContext(entry),
652
+ createContext: () => this.createEntryContext(record.entry),
548
653
  onLoadStart: promise => this.trackPendingLoad(promise)
549
654
  })
550
655
  }
551
656
 
552
657
  // Writes the computed value into the data, with the defaults of its nested
553
658
  // values, unless it equals the value in the data.
554
- writeComputedValue(entry, value) {
659
+ writeComputedValue(entry, computedValue) {
555
660
  const { data, name } = entry
556
- this.setNestedDefaultValues(entry, value)
661
+ const value = this.getValueWithNestedDefaultValues(entry, computedValue)
557
662
  if (!equals(value, data[name])) {
558
663
  const dataPath = this.getRelativeDataPath(entry)
559
664
  if (this.isWriteLoop(dataPath)) {
@@ -561,40 +666,71 @@ export class DataModel {
561
666
  }
562
667
  // Values written while the model settles are derived, see
563
668
  // `takeProcessedDataSnapshot()`:
564
- this.derivedValueDataPaths?.add(dataPath)
669
+ this.recordDerivedValueDataPath(dataPath)
565
670
  // Access `data[name]` directly to update the value without calling
566
671
  // `parse()`, see `ValueMixin`:
567
672
  data[name] = value
568
673
  }
569
674
  }
570
675
 
571
- // Sets the missing defaults of the values nested in the computed value of
572
- // the entry, e.g. of the items that `compute()` returns, as
573
- // `setDefaultValues()` would once the value is written. Otherwise, the two
574
- // would disagree on the value and keep replacing each other's writes.
575
- setNestedDefaultValues(entry, value) {
576
- const { schema, data, name, dataPath } = entry
577
- if (!isPlainObject(value) && !isArray(value)) {
578
- return
676
+ // Returns the computed value of the entry with the missing defaults of its
677
+ // nested values, e.g. of the items that `compute()` returns, as
678
+ // `setDefaultValues()` would set them once the value is written. Otherwise,
679
+ // the two would disagree on the value and keep replacing each other's
680
+ // writes. The value isn't modified, as it may be shared, e.g. a constant:
681
+ // The objects and arrays that hold missing values are copied, along with the
682
+ // ones that hold these, and the value is returned as it is when nothing is
683
+ // missing, keeping its identity, e.g. of options.
684
+ getValueWithNestedDefaultValues(entry, value) {
685
+ const { data, name } = entry
686
+ if (
687
+ !isPlainObject(value) && !isArray(value) ||
688
+ // The current value gets its defaults from `setDefaultValues()`:
689
+ toRaw(value) === toRaw(data[name])
690
+ ) {
691
+ return value
579
692
  }
580
- // Walk the computed value in place of the current one, in a copy of the
581
- // data that holds it.
693
+ const dataWithMissingValues = new Set()
694
+ this.processNestedEntries(entry, value, nestedEntry => {
695
+ if (this.shouldSetDefaultValue(nestedEntry)) {
696
+ dataWithMissingValues.add(nestedEntry.data)
697
+ }
698
+ })
699
+ if (dataWithMissingValues.size === 0) {
700
+ return value
701
+ }
702
+ const valueWithDefaults = copyContainersHolding(
703
+ value,
704
+ dataWithMissingValues
705
+ )
706
+ // Defaults may hold values with defaults of their own, which the walk
707
+ // visits after setting them:
708
+ this.processNestedEntries(entry, valueWithDefaults, nestedEntry => {
709
+ if (this.shouldSetDefaultValue(nestedEntry)) {
710
+ setDefaultValue(
711
+ nestedEntry.schema,
712
+ nestedEntry.data,
713
+ nestedEntry.name,
714
+ () => this.createEntryContext(nestedEntry)
715
+ )
716
+ }
717
+ })
718
+ return valueWithDefaults
719
+ }
720
+
721
+ // Walks the entries nested in `value`, as the value of the entry's
722
+ // component, in place of its current one in a copy of the data that holds it,
723
+ // and calls `handleNestedEntry()` before visiting each of them.
724
+ processNestedEntries(entry, value, handleNestedEntry) {
725
+ const { schema, data, name, dataPath } = entry
582
726
  const valueData = { ...data, [name]: value }
583
727
  processSchemaData({ components: { [name]: schema } }, valueData, {
584
728
  dataPath: isNested(schema) ? getParentDataPath(dataPath) : dataPath,
585
- shouldProcess: nestedEntry => this.isEntryShown(nestedEntry),
729
+ shouldProcess: nestedEntry => this.shouldRenderEntry(nestedEntry),
586
730
  shouldSkipSourcesWithResource: true,
587
731
  before: nestedEntry => {
588
- if (
589
- nestedEntry.data !== valueData &&
590
- this.shouldSetDefaultValue(nestedEntry)
591
- ) {
592
- setDefaultValue(
593
- nestedEntry.schema,
594
- nestedEntry.data,
595
- nestedEntry.name,
596
- () => this.createEntryContext(nestedEntry)
597
- )
732
+ if (nestedEntry.data !== valueData) {
733
+ handleNestedEntry(nestedEntry)
598
734
  }
599
735
  },
600
736
  options: { component: this.component, rootData: this.rootData }
@@ -629,10 +765,10 @@ export class DataModel {
629
765
  // data, as in its processed data.
630
766
  getRelativeDataPath({ schema, name, dataPath }) {
631
767
  // The data paths of nested components include their own name.
632
- const tokens = isNested(schema)
633
- ? parseDataPath(dataPath)
634
- : [...parseDataPath(dataPath), name]
635
- return tokens.slice(parseDataPath(this.dataPath).length).join('/')
768
+ const valueDataPath = isNested(schema)
769
+ ? dataPath
770
+ : appendDataPath(dataPath, name)
771
+ return getRelativeDataPath(valueDataPath, this.dataPath)
636
772
  }
637
773
 
638
774
  // Returns the context for `if`, data schemas and options, with the component
@@ -719,6 +855,32 @@ function takeOverChangedValues(target, before, after) {
719
855
  return result
720
856
  }
721
857
 
858
+ // Returns `value` with the plain objects and arrays in it that are one of
859
+ // `objects` or hold one of them copied, shallowly, and the others as they are.
860
+ // Values that occur more than once are copied at each occurrence, so that
861
+ // setting defaults in the copies never reaches the originals. Only the values
862
+ // that hold themselves, `ancestorValues`, are left as they are.
863
+ function copyContainersHolding(value, objects, ancestorValues = new Set()) {
864
+ if (
865
+ !isPlainObject(value) && !isArray(value) ||
866
+ ancestorValues.has(value)
867
+ ) {
868
+ return value
869
+ }
870
+ ancestorValues.add(value)
871
+ const copyValue = () => (isArray(value) ? [...value] : { ...value })
872
+ let copy = objects.has(value) ? copyValue() : null
873
+ for (const [key, entry] of Object.entries(value)) {
874
+ const entryCopy = copyContainersHolding(entry, objects, ancestorValues)
875
+ if (entryCopy !== entry) {
876
+ copy ??= copyValue()
877
+ copy[key] = entryCopy
878
+ }
879
+ }
880
+ ancestorValues.delete(value)
881
+ return copy ?? value
882
+ }
883
+
722
884
  const notFound = Symbol('notFound')
723
885
 
724
886
  // The watchers of the model that set up the data and the scopes of its
@@ -745,6 +907,66 @@ const optionsNotLoaded = Symbol('optionsNotLoaded')
745
907
  // are considered a loop, see `DataModel.isWriteLoop()`.
746
908
  const maxComputedValueWrites = 10
747
909
 
910
+ // The milliseconds after which `DataModel.waitUntilSettled()` stops waiting
911
+ // for pending loads, long enough for slow requests.
912
+ const settleTimeout = 30_000
913
+
914
+ // Creates the record of the component of the entry, see
915
+ // `DataModel.componentRecords`: its current entry, the scope of the watchers
916
+ // that write its computed values, and the resolver of its options, with the
917
+ // options schema that it resolves, once they're read.
918
+ function createComponentRecord(entry) {
919
+ return {
920
+ entry,
921
+ // The data path of the entry, read by computes, see
922
+ // `getComputedValueResult()`. Unlike `entry`, it's reactive, as items
923
+ // that move keep their records.
924
+ dataPath: shallowRef(entry.dataPath),
925
+ computedValueScope: null,
926
+ optionsResolver: null,
927
+ optionsSchema: null
928
+ }
929
+ }
930
+
931
+ // Holds values by the data and name of the entries of components, see
932
+ // `DataModel.componentRecords`. The data is compared by its raw object, as it
933
+ // may be passed through reactive proxies.
934
+ class EntryMap {
935
+ valuesByData = new Map()
936
+
937
+ get({ data, name }) {
938
+ return this.valuesByData.get(toRaw(data))?.get(name)
939
+ }
940
+
941
+ set({ data, name }, value) {
942
+ const rawData = toRaw(data)
943
+ let valuesByName = this.valuesByData.get(rawData)
944
+ if (!valuesByName) {
945
+ valuesByName = new Map()
946
+ this.valuesByData.set(rawData, valuesByName)
947
+ }
948
+ valuesByName.set(name, value)
949
+ }
950
+
951
+ delete({ data, name }) {
952
+ const rawData = toRaw(data)
953
+ const valuesByName = this.valuesByData.get(rawData)
954
+ if (valuesByName?.delete(name) && valuesByName.size === 0) {
955
+ this.valuesByData.delete(rawData)
956
+ }
957
+ }
958
+
959
+ clear() {
960
+ this.valuesByData.clear()
961
+ }
962
+
963
+ *values() {
964
+ for (const valuesByName of this.valuesByData.values()) {
965
+ yield* valuesByName.values()
966
+ }
967
+ }
968
+ }
969
+
748
970
  // Returns whether the schema is a source of computed values, through
749
971
  // `schema.compute()` or a data schema of the `computed` types.
750
972
  function hasComputedValueSource(schema) {