@ditojs/admin 2.100.0 → 3.1.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 (81) hide show
  1. package/dist/dito-admin.css +1 -1
  2. package/dist/dito-admin.es.js +3555 -2719
  3. package/dist/dito-admin.umd.js +5 -6
  4. package/package.json +84 -46
  5. package/src/DitoAdmin.js +1 -65
  6. package/src/DitoComponent.js +1 -1
  7. package/src/DitoContext.js +51 -0
  8. package/src/DitoTypeComponent.js +23 -1
  9. package/src/components/DitoAffixes.vue +6 -3
  10. package/src/components/DitoButtons.vue +14 -1
  11. package/src/components/DitoContainer.vue +19 -10
  12. package/src/components/DitoCreateButton.vue +11 -2
  13. package/src/components/DitoDialog.vue +21 -2
  14. package/src/components/DitoForm.vue +152 -84
  15. package/src/components/DitoLabel.vue +3 -2
  16. package/src/components/DitoPane.vue +55 -11
  17. package/src/components/DitoPanel.vue +34 -5
  18. package/src/components/DitoPanels.vue +2 -1
  19. package/src/components/DitoRoot.vue +6 -6
  20. package/src/components/DitoRouterView.vue +27 -0
  21. package/src/components/DitoSchema.vue +278 -145
  22. package/src/components/DitoSchemaInlined.vue +1 -1
  23. package/src/components/DitoTabs.vue +16 -18
  24. package/src/components/DitoTrail.vue +5 -4
  25. package/src/components/DitoTreeItem.vue +17 -6
  26. package/src/components/DitoView.vue +21 -3
  27. package/src/components/index.js +1 -0
  28. package/src/mixins/DitoMixin.js +53 -7
  29. package/src/mixins/ItemMixin.js +3 -6
  30. package/src/mixins/NumberMixin.js +47 -31
  31. package/src/mixins/OptionsMixin.js +132 -71
  32. package/src/mixins/ResourceMixin.js +5 -2
  33. package/src/mixins/RouteMixin.js +39 -1
  34. package/src/mixins/SortableMixin.js +0 -13
  35. package/src/mixins/SourceMixin.js +155 -96
  36. package/src/mixins/TextMixin.js +1 -1
  37. package/src/mixins/TypeMixin.js +37 -22
  38. package/src/mixins/ValidationMixin.js +9 -27
  39. package/src/mixins/ValidatorMixin.js +5 -7
  40. package/src/mixins/ValueMixin.js +5 -2
  41. package/src/types/DitoTypeButton.vue +1 -1
  42. package/src/types/DitoTypeCheckbox.vue +1 -1
  43. package/src/types/DitoTypeCheckboxes.vue +1 -1
  44. package/src/types/DitoTypeCode.vue +8 -1
  45. package/src/types/DitoTypeColor.vue +20 -34
  46. package/src/types/DitoTypeComponent.vue +1 -1
  47. package/src/types/DitoTypeComputed.vue +4 -27
  48. package/src/types/DitoTypeDate.vue +16 -5
  49. package/src/types/DitoTypeLabel.vue +1 -4
  50. package/src/types/DitoTypeList.vue +9 -5
  51. package/src/types/DitoTypeMarkup.vue +25 -13
  52. package/src/types/DitoTypeMultiselect.vue +37 -6
  53. package/src/types/DitoTypeNumber.vue +1 -1
  54. package/src/types/DitoTypeObject.vue +10 -7
  55. package/src/types/DitoTypePanel.vue +20 -6
  56. package/src/types/DitoTypeProgress.vue +1 -1
  57. package/src/types/DitoTypeRadio.vue +1 -1
  58. package/src/types/DitoTypeSection.vue +32 -4
  59. package/src/types/DitoTypeSelect.vue +1 -1
  60. package/src/types/DitoTypeSlider.vue +1 -1
  61. package/src/types/DitoTypeSwitch.vue +1 -1
  62. package/src/types/DitoTypeText.vue +13 -15
  63. package/src/types/DitoTypeTextarea.vue +1 -1
  64. package/src/types/DitoTypeTreeList.vue +5 -7
  65. package/src/types/DitoTypeUpload.vue +1 -2
  66. package/src/utils/DataSchemaResolver.js +154 -0
  67. package/src/utils/FormModel.js +615 -0
  68. package/src/utils/data.js +20 -0
  69. package/src/utils/filter.js +3 -2
  70. package/src/utils/options.js +3 -0
  71. package/src/utils/request.js +70 -0
  72. package/src/utils/schema/data.js +659 -0
  73. package/src/utils/schema/lookup.js +250 -0
  74. package/src/utils/schema/setup.js +354 -0
  75. package/src/utils/schema/structure.js +172 -0
  76. package/src/utils/schema/types.js +34 -0
  77. package/src/utils/schema/validation.js +142 -0
  78. package/src/utils/units.js +70 -3
  79. package/types/index.d.ts +174 -59
  80. package/src/mixins/DataMixin.js +0 -131
  81. package/src/utils/schema.js +0 -1121
@@ -0,0 +1,615 @@
1
+ import { effectScope, computed, watch, nextTick, shallowRef } from 'vue'
2
+ import {
3
+ isArray,
4
+ isPlainObject,
5
+ equals,
6
+ clone,
7
+ parseDataPath,
8
+ getValueAtDataPath
9
+ } from '@ditojs/utils'
10
+ import DitoContext from '../DitoContext.js'
11
+ import { DataSchemaResolver } from './DataSchemaResolver.js'
12
+ import { isNested, isSourceWithResource } from './schema/structure.js'
13
+ import { isEmptySchema } from './schema/lookup.js'
14
+ import {
15
+ getSchemaValue,
16
+ shouldSetDefaultValue,
17
+ getDefaultValue,
18
+ initializeData,
19
+ computeValue,
20
+ hasValueFromDataSchema,
21
+ processSchemaData,
22
+ processData
23
+ } from './schema/data.js'
24
+
25
+ // FormModel holds the state of the data edited by a form, view or dialog that
26
+ // is derived from schema and data, independently of what is rendered:
27
+ //
28
+ // - Missing values are set to their defaults when the data is set up, and when
29
+ // they go missing later, e.g. in items that code adds, except the values of
30
+ // components with `compute()`, which fall back to their defaults when
31
+ // `compute()` doesn't return a value, unlike in new data, see
32
+ // `initializeData()`.
33
+ // - The items of lists with `orderKey` are numbered when the data is set up,
34
+ // which doesn't make the data dirty.
35
+ // - Computed values, the results of `schema.compute()` and of the data schemas
36
+ // of the `computed` types (`schema.data`, `schema.dataPath`), are written
37
+ // into the data by watchers that the model owns, one scope per component,
38
+ // for all components whose `if` doesn't evaluate to `false`.
39
+ // - Options, `schema.options`, are resolved per component when they are first
40
+ // read, by `compute()` through `context.options` or by the component that
41
+ // displays them, see `getOptions()`. Both get the same option objects, and
42
+ // computes that read options run again when they are loaded.
43
+ // - Submitting waits for the loads of data schemas and options that are still
44
+ // pending, see `waitForPendingLoads()`.
45
+ // - The data is dirty when its processed data differs from a snapshot taken
46
+ // when the data was set up, saved or applied, see `isDirty`. The values that
47
+ // the model derives until it settled, e.g. from loaded options, don't count,
48
+ // see `takeProcessedDataSnapshot()`. Only forms track this, by passing
49
+ // `getSourceSchema()`, see the constructor.
50
+ //
51
+ // Sources with their own resource are skipped, as their items are edited
52
+ // through their own forms.
53
+ //
54
+ // The model handles components through their entries, the objects that
55
+ // `processSchemaData()` passes to `before()` and `after()`, with the schema,
56
+ // data, name, data path and component path of each component. Components that
57
+ // read their options pass entries of the same shape, see `getOptions()`.
58
+ //
59
+ // `component` is the component that owns the data, e.g. `DitoForm`. Its
60
+ // `dataPath`, `componentPath`, `rootData` and `mainSchemaComponent` are used
61
+ // when present. The model needs to be stopped before the component unmounts.
62
+
63
+ export class FormModel {
64
+ // The entries of the components with computed values and the scopes of the
65
+ // watchers that write these values into the data, by component path:
66
+ computedValueRecords = new Map()
67
+ // The entries of the components whose options were read and the resolvers
68
+ // of their options, by component path, see `getOptionsResolver()`:
69
+ optionsRecords = new Map()
70
+ // The promises of the loads of data schemas and options that are pending,
71
+ // see `waitForPendingLoads()`:
72
+ pendingLoads = new Set()
73
+ // The data that the snapshot was taken of, and its processed data to compare
74
+ // with in `isDirty`, see `takeProcessedDataSnapshot()`:
75
+ processedDataSnapshot = shallowRef(null)
76
+ // The data paths of the values that the model writes while it settles after
77
+ // the snapshot is taken, which `isDirty` takes from the current data, `null`
78
+ // once it settled, see `takeProcessedDataSnapshot()`:
79
+ derivedValueDataPaths = null
80
+
81
+ // `getSchema()` and `getData()` return the schema and the data that the
82
+ // model handles. `getSourceSchema()` returns the schema of the source that
83
+ // the data is an item of, as needed by `processData()`. Only forms pass it,
84
+ // to track whether their data is dirty, see `isDirty`.
85
+ constructor({ component, getSchema, getData, getSourceSchema = null }) {
86
+ this.component = component
87
+ this.getSchema = getSchema
88
+ this.getData = getData
89
+ this.getSourceSchema = getSourceSchema
90
+ this.rootScope = effectScope(true)
91
+ this.rootScope.run(() => {
92
+ watch(
93
+ [getSchema, getData],
94
+ ([schema, data]) => this.initializeData(schema, data),
95
+ { immediate: true }
96
+ )
97
+ // The entries are read through a computed property, so that the watchers
98
+ // of the entries can check synchronously whether their entry is still
99
+ // current, see `createComputedValueScope()`.
100
+ this.dataEntries = computed(() => this.getDataEntries())
101
+ watch(
102
+ () => this.dataEntries.value.computedValueEntries,
103
+ entries => this.updateComputedValueRecords(entries),
104
+ { immediate: true }
105
+ )
106
+ // Values that go missing after the data was set up, e.g. in items that
107
+ // code adds, get their defaults too:
108
+ watch(
109
+ () => this.dataEntries.value.entriesWithMissingValues,
110
+ entries => this.setDefaultValues(entries),
111
+ { immediate: true }
112
+ )
113
+ if (getSourceSchema) {
114
+ // Data that is set up, e.g. loaded, saved or applied, isn't dirty:
115
+ watch(
116
+ getData,
117
+ () => this.takeProcessedDataSnapshot().catch(console.error),
118
+ { immediate: true }
119
+ )
120
+ }
121
+ })
122
+ }
123
+
124
+ get dataPath() {
125
+ return this.component.dataPath ?? ''
126
+ }
127
+
128
+ get componentPath() {
129
+ return this.component.componentPath ?? ''
130
+ }
131
+
132
+ get rootData() {
133
+ return this.component.rootData ?? this.getData()
134
+ }
135
+
136
+ // Returns whether the processed data differs from the snapshot taken when
137
+ // the data was set up, saved or applied, see `takeProcessedDataSnapshot()`.
138
+ // Excluded values don't count, unless their components have `process()`,
139
+ // see `processData()`, and neither do the values that the model derives
140
+ // until it settled.
141
+ get isDirty() {
142
+ const processedDataSnapshot = this.processedDataSnapshot.value
143
+ const data = this.getData()
144
+ // A snapshot only applies to the data that it was taken of. Data that was
145
+ // replaced since isn't dirty until its own snapshot is taken, and neither
146
+ // is missing data.
147
+ if (!data || processedDataSnapshot?.data !== data) {
148
+ return false
149
+ }
150
+ const processedData = this.getProcessedDataForDirtyCheck()
151
+ const processedDataToCompare = this.derivedValueDataPaths
152
+ ? takeOverValuesAtDataPaths(
153
+ processedDataSnapshot.processedData,
154
+ processedData,
155
+ this.derivedValueDataPaths
156
+ )
157
+ : processedDataSnapshot.processedData
158
+ return !equals(processedData, processedDataToCompare)
159
+ }
160
+
161
+ stop() {
162
+ this.rootScope.stop()
163
+ this.computedValueRecords.clear()
164
+ this.optionsRecords.clear()
165
+ this.pendingLoads.clear()
166
+ }
167
+
168
+ // Waits for the pending loads of data schemas and options, including the
169
+ // ones that they cause, e.g. options that depend on computed values that
170
+ // depend on loaded options, and for the watchers to write the resulting
171
+ // computed values into the data.
172
+ async waitForPendingLoads() {
173
+ while (this.pendingLoads.size > 0) {
174
+ await Promise.all(this.pendingLoads)
175
+ await nextTick()
176
+ }
177
+ }
178
+
179
+ // Takes the snapshot of the processed data that `isDirty` compares with,
180
+ // when the data is set up, saved or applied. Until the model settled, i.e.
181
+ // the pending loads finished and the computed values that depend on them are
182
+ // written into the data, see `waitForPendingLoads()`, the values that the
183
+ // model writes are recorded in `derivedValueDataPaths` and don't count, as
184
+ // they're derived from the data, e.g. from loaded options. Once settled, they
185
+ // are taken over into the snapshot, so that changes made in the meantime,
186
+ // e.g. by the user, count right away. Values that the model derives from
187
+ // these changes while it settles are taken over too, so reverting the
188
+ // changes afterwards leaves the data dirty, which only asks for confirmation
189
+ // unnecessarily.
190
+ async takeProcessedDataSnapshot() {
191
+ const data = this.getData()
192
+ const derivedValueDataPaths = data ? new Set() : null
193
+ this.derivedValueDataPaths = derivedValueDataPaths
194
+ this.processedDataSnapshot.value = data
195
+ ? { data, processedData: clone(this.getProcessedDataForDirtyCheck()) }
196
+ : null
197
+ if (!data) {
198
+ return
199
+ }
200
+ // Let the watchers and the rendering start their loads first:
201
+ await nextTick()
202
+ await this.waitForPendingLoads()
203
+ // Snapshots that were taken since settle by themselves.
204
+ const isSnapshotReplaced = (
205
+ this.derivedValueDataPaths !==
206
+ derivedValueDataPaths
207
+ )
208
+ // Check whether the model stopped first, as its component may be gone.
209
+ const isModelStopped = !this.rootScope.active
210
+ if (isSnapshotReplaced || isModelStopped) {
211
+ return
212
+ }
213
+ this.derivedValueDataPaths = null
214
+ const processedDataSnapshot = this.processedDataSnapshot.value
215
+ if (processedDataSnapshot?.data === this.getData()) {
216
+ this.processedDataSnapshot.value = {
217
+ data,
218
+ processedData: takeOverValuesAtDataPaths(
219
+ processedDataSnapshot.processedData,
220
+ this.getProcessedDataForDirtyCheck(),
221
+ derivedValueDataPaths
222
+ )
223
+ }
224
+ }
225
+ }
226
+
227
+ // Returns the processed data that `isDirty` compares with
228
+ // `processedDataSnapshot`, and that the snapshot is taken of. The data is
229
+ // processed like for the clipboard, not for the server, which gives
230
+ // references to new items random prefixes each time, see
231
+ // `SchemaGraph.getReferencePrefix()`.
232
+ getProcessedDataForDirtyCheck() {
233
+ const schema = this.getSchema()
234
+ const data = this.getData()
235
+ return data && !isEmptySchema(schema)
236
+ ? processData(schema, this.getSourceSchema(), data, this.dataPath, {
237
+ // Like `getComputedValue()`:
238
+ component: this.component.mainSchemaComponent ?? this.component,
239
+ rootData: this.rootData,
240
+ schemaOnly: true,
241
+ target: 'clipboard',
242
+ // Reading the dirty state doesn't call `compute()` and `process()`,
243
+ // see `processData()`:
244
+ shouldCallComputeAndProcess: false
245
+ })
246
+ : null
247
+ }
248
+
249
+ // Makes clean changes to the data, which don't make it dirty, while other
250
+ // changes still do, e.g. to apply what an action on the server already
251
+ // saved: Once the model settled, the values that changed in the processed
252
+ // data, including the values derived from the changes, are taken over into
253
+ // the snapshot. Arrays whose length changes are taken over as a whole, see
254
+ // `takeOverChangedValues()`. `makeChanges()` is called synchronously.
255
+ async applyCleanChanges(makeChanges) {
256
+ const data = this.getData()
257
+ const hasSnapshotOfData = this.processedDataSnapshot.value?.data === data
258
+ // Clone the processed data, as it shares nested values with the data,
259
+ // which `makeChanges()` may change in place.
260
+ const processedDataBeforeChanges = hasSnapshotOfData
261
+ ? clone(this.getProcessedDataForDirtyCheck())
262
+ : null
263
+ makeChanges()
264
+ if (hasSnapshotOfData) {
265
+ // Let the watchers write the values derived from the changes first:
266
+ await nextTick()
267
+ await this.waitForPendingLoads()
268
+ // Take the changes over into the current snapshot of the data, which may
269
+ // have been replaced in the meantime, e.g. once the model settled.
270
+ const processedDataSnapshot = this.processedDataSnapshot.value
271
+ if (processedDataSnapshot?.data === data && this.rootScope.active) {
272
+ this.processedDataSnapshot.value = {
273
+ data,
274
+ processedData: takeOverChangedValues(
275
+ processedDataSnapshot.processedData,
276
+ processedDataBeforeChanges,
277
+ this.getProcessedDataForDirtyCheck()
278
+ )
279
+ }
280
+ }
281
+ }
282
+ }
283
+
284
+ // Tracks the promise of a pending load until it settles, so that
285
+ // `waitForPendingLoads()` waits for it.
286
+ trackPendingLoad(promise) {
287
+ this.pendingLoads.add(promise)
288
+ promise.finally(() => this.pendingLoads.delete(promise))
289
+ }
290
+
291
+ // Sets missing values to their defaults and numbers the items of lists by
292
+ // their order key when the data is set up, see `initializeData()`.
293
+ initializeData(schema, data) {
294
+ if (data && !isEmptySchema(schema)) {
295
+ initializeData(schema, data, this.component, {
296
+ dataPath: this.dataPath,
297
+ rootData: this.rootData,
298
+ shouldProcess: entry => !isSourceWithResource(entry.schema),
299
+ // The model writes the values of components with `compute()`,
300
+ // including their defaults:
301
+ shouldSetDefaultsOfComponentsWithCompute: false
302
+ })
303
+ }
304
+ }
305
+
306
+ // Walks the data and returns the entries of `processSchemaData()` of the
307
+ // components whose `if` doesn't evaluate to `false`: the ones with computed
308
+ // values, by component path, and the ones whose values are missing. Called
309
+ // by a computed property, so that it runs again when the data structure
310
+ // changes, e.g. when list items are added or removed, values go missing, or
311
+ // `if` conditions change.
312
+ getDataEntries() {
313
+ const schema = this.getSchema()
314
+ const data = this.getData()
315
+ const computedValueEntries = new Map()
316
+ const entriesWithMissingValues = []
317
+ if (data && !isEmptySchema(schema)) {
318
+ processSchemaData(schema, data, {
319
+ dataPath: this.dataPath,
320
+ componentPath: this.componentPath,
321
+ shouldProcess: entry => (
322
+ !isSourceWithResource(entry.schema) && this.isEntryShown(entry)
323
+ ),
324
+ before: entry => {
325
+ if (hasComputedValueSource(entry.schema)) {
326
+ computedValueEntries.set(entry.componentPath, entry)
327
+ }
328
+ if (this.shouldSetDefaultValue(entry)) {
329
+ entriesWithMissingValues.push(entry)
330
+ }
331
+ },
332
+ options: { component: this.component, rootData: this.rootData }
333
+ })
334
+ }
335
+ return { computedValueEntries, entriesWithMissingValues }
336
+ }
337
+
338
+ // Returns whether the `if` of the entry's component doesn't evaluate to
339
+ // `false`. Unlike `shouldRenderSchema()`, it doesn't evaluate the components
340
+ // of sections and tabs, as the walk of the data visits them anyway, see
341
+ // `getDataEntries()`, which leads to the same entries.
342
+ isEntryShown(entry) {
343
+ return (
344
+ entry.schema.if === undefined ||
345
+ getSchemaValue('if', {
346
+ type: Boolean,
347
+ schema: entry.schema,
348
+ context: this.createEntryContext(entry),
349
+ default: true
350
+ })
351
+ )
352
+ }
353
+
354
+ // Returns whether the value of the entry is missing and needs its default.
355
+ // The model writes the values of components with `compute()`, including
356
+ // their defaults, see `computeValue()`.
357
+ shouldSetDefaultValue(entry) {
358
+ const { schema, data, name } = entry
359
+ return shouldSetDefaultValue(
360
+ schema,
361
+ data,
362
+ name,
363
+ () => this.createEntryContext(entry),
364
+ { shouldSetDefaultsOfComponentsWithCompute: false }
365
+ )
366
+ }
367
+
368
+ // Sets the defaults of the values of the entries, unless they were set in
369
+ // the meantime.
370
+ setDefaultValues(entries) {
371
+ for (const entry of entries) {
372
+ if (this.shouldSetDefaultValue(entry)) {
373
+ const { schema, data, name } = entry
374
+ data[name] = getDefaultValue(schema, () =>
375
+ this.createEntryContext(entry)
376
+ )
377
+ }
378
+ }
379
+ }
380
+
381
+ // Keeps the records of the components that are still present with the same
382
+ // schema and data, creates records with new scopes for new ones, and stops
383
+ // the scopes of the others.
384
+ updateComputedValueRecords(entries) {
385
+ const previousRecords = this.computedValueRecords
386
+ this.computedValueRecords = new Map()
387
+ for (const [componentPath, entry] of entries) {
388
+ let computedValueRecord = previousRecords.get(componentPath)
389
+ if (
390
+ computedValueRecord?.entry.schema === entry.schema &&
391
+ computedValueRecord.entry.data === entry.data
392
+ ) {
393
+ previousRecords.delete(componentPath)
394
+ } else {
395
+ computedValueRecord = {
396
+ entry,
397
+ scope: this.createComputedValueScope(entry)
398
+ }
399
+ }
400
+ this.computedValueRecords.set(componentPath, computedValueRecord)
401
+ }
402
+ for (const { scope } of previousRecords.values()) {
403
+ scope.stop()
404
+ }
405
+ }
406
+
407
+ // Creates the scope of the watchers that write the computed values of the
408
+ // entry into the data. Entries stop being current before their scope is
409
+ // stopped, as watchers that don't belong to components run in the order in
410
+ // which they're triggered, e.g. when list items are removed or `if`
411
+ // conditions change. The watchers skip these entries, see `isEntryCurrent()`.
412
+ createComputedValueScope(entry) {
413
+ const scope = this.rootScope.run(() => effectScope())
414
+ scope.run(() => {
415
+ const isEntryCurrent = computed(() => this.isEntryCurrent(entry))
416
+ if (entry.schema.compute) {
417
+ watch(
418
+ // Return a new object each time, so that the value is also written
419
+ // when only the value in the data changed, e.g. through user input.
420
+ () =>
421
+ isEntryCurrent.value
422
+ ? { value: this.getComputedValue(entry) }
423
+ : null,
424
+ computedResult => {
425
+ if (computedResult) {
426
+ this.writeComputedValue(entry, computedResult.value)
427
+ }
428
+ },
429
+ { immediate: true }
430
+ )
431
+ }
432
+ if (hasValueFromDataSchema(entry.schema)) {
433
+ const dataSchemaResolver = this.createDataSchemaResolver(
434
+ entry.schema,
435
+ entry
436
+ )
437
+ watch(
438
+ () =>
439
+ isEntryCurrent.value && !dataSchemaResolver.isLoading
440
+ ? { value: dataSchemaResolver.value }
441
+ : null,
442
+ resolved => {
443
+ if (resolved) {
444
+ this.writeComputedValue(entry, resolved.value)
445
+ }
446
+ },
447
+ { immediate: true }
448
+ )
449
+ }
450
+ })
451
+ return scope
452
+ }
453
+
454
+ // Returns whether the entry is still one of the current entries, with the
455
+ // same schema and data, see `getDataEntries()`.
456
+ isEntryCurrent({ componentPath, schema, data }) {
457
+ const currentEntry =
458
+ this.dataEntries.value.computedValueEntries.get(componentPath)
459
+ return currentEntry?.schema === schema && currentEntry.data === data
460
+ }
461
+
462
+ // Calls `schema.compute()` with the main schema component of the data, like
463
+ // `processData()`, and with the options resolved by the model, so that the
464
+ // result doesn't depend on whether the component is rendered.
465
+ getComputedValue(entry) {
466
+ const { schema, data, name, dataPath } = entry
467
+ return computeValue(schema, data, name, dataPath, {
468
+ component: this.component.mainSchemaComponent ?? this.component,
469
+ rootData: this.rootData,
470
+ getOptions: schema.options ? () => this.getOptions(entry) : null
471
+ })
472
+ }
473
+
474
+ // Returns the options of the component of the entry, resolved from
475
+ // `schema.options` when they're first read, `undefined` while loading.
476
+ getOptions(entry) {
477
+ return this.getOptionsResolver(entry).value
478
+ }
479
+
480
+ // Returns the resolver of the options of the component of the entry, shared
481
+ // by all callers with the same component path, schema and data, so that
482
+ // computes and the component that displays the options get the same option
483
+ // objects, and the options only load once.
484
+ getOptionsResolver(entry) {
485
+ const { schema, data, componentPath } = entry
486
+ let optionsRecord = this.optionsRecords.get(componentPath)
487
+ if (
488
+ !optionsRecord ||
489
+ optionsRecord.entry.schema.options !== schema.options ||
490
+ optionsRecord.entry.data !== data
491
+ ) {
492
+ optionsRecord = {
493
+ entry,
494
+ resolver: this.createDataSchemaResolver(schema.options, entry)
495
+ }
496
+ this.optionsRecords.set(componentPath, optionsRecord)
497
+ }
498
+ return optionsRecord.resolver
499
+ }
500
+
501
+ createDataSchemaResolver(dataSchema, entry) {
502
+ return new DataSchemaResolver(dataSchema, {
503
+ createContext: () => this.createEntryContext(entry),
504
+ onLoadStart: promise => this.trackPendingLoad(promise)
505
+ })
506
+ }
507
+
508
+ writeComputedValue(entry, value) {
509
+ const { data, name } = entry
510
+ if (!equals(value, data[name])) {
511
+ // Values written while the model settles are derived, see
512
+ // `takeProcessedDataSnapshot()`:
513
+ this.derivedValueDataPaths?.add(this.getRelativeDataPath(entry))
514
+ // Access `data[name]` directly to update the value without calling
515
+ // `parse()`, see `ValueMixin`:
516
+ data[name] = value
517
+ }
518
+ }
519
+
520
+ // Returns the data path of the value of the entry, relative to the model's
521
+ // data, as in its processed data.
522
+ getRelativeDataPath({ schema, name, dataPath }) {
523
+ // The data paths of nested components include their own name.
524
+ const tokens = isNested(schema)
525
+ ? parseDataPath(dataPath)
526
+ : [...parseDataPath(dataPath), name]
527
+ return tokens.slice(parseDataPath(this.dataPath).length).join('/')
528
+ }
529
+
530
+ // Returns the context for `if`, data schemas and options, with the component
531
+ // that owns the data, which stays the same when components are mounted, so
532
+ // that data schemas and options don't load again.
533
+ createEntryContext({ schema, data, name, dataPath }) {
534
+ return DitoContext.createForSchema(this.component, {
535
+ schema,
536
+ name,
537
+ data,
538
+ dataPath,
539
+ rootData: this.rootData
540
+ })
541
+ }
542
+ }
543
+
544
+ // Returns a copy of `processedData` with the values at `dataPaths` taken over
545
+ // from `currentProcessedData`, or removed if they're missing there. Values
546
+ // whose parents are missing in `processedData` are left out, as their parents
547
+ // differ anyway.
548
+ function takeOverValuesAtDataPaths(
549
+ processedData,
550
+ currentProcessedData,
551
+ dataPaths
552
+ ) {
553
+ if (dataPaths.size === 0) {
554
+ return processedData
555
+ }
556
+ const result = clone(processedData)
557
+ for (const dataPath of dataPaths) {
558
+ const tokens = parseDataPath(dataPath)
559
+ const parentDataPath = tokens.slice(0, -1)
560
+ const key = tokens.at(-1)
561
+ const parent = getValueAtDataPath(result, parentDataPath, () => null)
562
+ if (parent && typeof parent === 'object') {
563
+ const value = getValueAtDataPath(
564
+ currentProcessedData,
565
+ dataPath,
566
+ () => notFound
567
+ )
568
+ if (value === notFound) {
569
+ delete parent[key]
570
+ } else {
571
+ parent[key] = clone(value)
572
+ }
573
+ }
574
+ }
575
+ return result
576
+ }
577
+
578
+ // Returns a copy of `target` with the values that differ between `before` and
579
+ // `after` taken over from `after`, comparing objects and arrays of the same
580
+ // length entry by entry. Arrays whose length changed are taken over as a whole,
581
+ // as their entries can't be matched, including changes of their entries that
582
+ // were made before.
583
+ function takeOverChangedValues(target, before, after) {
584
+ if (equals(before, after)) {
585
+ return target
586
+ }
587
+ const isSameShape = (
588
+ isPlainObject(before) && isPlainObject(after) && isPlainObject(target) ||
589
+ isArray(before) &&
590
+ isArray(after) &&
591
+ isArray(target) &&
592
+ before.length === after.length &&
593
+ after.length === target.length
594
+ )
595
+ if (!isSameShape) {
596
+ return clone(after)
597
+ }
598
+ const result = isArray(target) ? [...target] : { ...target }
599
+ for (const key of new Set([...Object.keys(before), ...Object.keys(after)])) {
600
+ if (!(key in after)) {
601
+ delete result[key]
602
+ } else {
603
+ result[key] = takeOverChangedValues(target[key], before[key], after[key])
604
+ }
605
+ }
606
+ return result
607
+ }
608
+
609
+ const notFound = Symbol('notFound')
610
+
611
+ // Returns whether the schema is a source of computed values, through
612
+ // `schema.compute()` or a data schema of the `computed` types.
613
+ function hasComputedValueSource(schema) {
614
+ return !!schema.compute || hasValueFromDataSchema(schema)
615
+ }
package/src/utils/data.js CHANGED
@@ -11,6 +11,26 @@ export function appendDataPath(dataPath, token) {
11
11
  : token
12
12
  }
13
13
 
14
+ /**
15
+ * Returns the part of `dataPath` inside `parentDataPath`, e.g. `0/title` for
16
+ * `chapters/0/title` in `chapters`, an empty string if both are the same, or
17
+ * `null` if `dataPath` isn't inside `parentDataPath`.
18
+ */
19
+ export function getRelativeDataPath(dataPath, parentDataPath) {
20
+ if (dataPath == null || parentDataPath == null) {
21
+ return null
22
+ }
23
+ const path = normalizeDataPath(dataPath)
24
+ const parentPath = normalizeDataPath(parentDataPath)
25
+ return path === parentPath
26
+ ? ''
27
+ : !parentPath
28
+ ? path
29
+ : path.startsWith(`${parentPath}/`)
30
+ ? path.slice(parentPath.length + 1)
31
+ : null
32
+ }
33
+
14
34
  export function parseParentDataPath(dataPath) {
15
35
  const path = parseDataPath(dataPath)
16
36
  path?.pop()
@@ -1,5 +1,6 @@
1
1
  import { isArray, asArray, labelize } from '@ditojs/utils'
2
- import { getNamedSchemas, processNestedSchemaDefaults } from './schema'
2
+ import { getNamedSchemas } from './schema/lookup.js'
3
+ import { applyNestedSchemaDefaults } from './schema/setup.js'
3
4
 
4
5
  export const filterComponents = {
5
6
  'text'(filter) {
@@ -119,7 +120,7 @@ export function createFiltersPanel(api, filters, dataPath, query) {
119
120
  }
120
121
  }
121
122
  }
122
- processNestedSchemaDefaults(api, panel)
123
+ applyNestedSchemaDefaults(api, panel)
123
124
  return panel
124
125
  }
125
126
 
@@ -36,8 +36,11 @@ const ditoOptionKeys = [
36
36
  'generateLabel',
37
37
  'excludeValue',
38
38
  'ignoreMissingValue',
39
+ 'treatNullAsMissing',
40
+ 'valueFromDataSchema',
39
41
  'omitSpacing',
40
42
  'processValue',
43
+ 'getTypeValidations',
41
44
  'processSchema',
42
45
  'getPanelSchema',
43
46
  'getFormSchemasForProcessing',