@ditojs/admin 3.2.4 → 3.2.6

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.
@@ -37,6 +37,21 @@
37
37
  @tag="onAddTag"
38
38
  @search-change="onSearchChange"
39
39
  )
40
+ //- The default tag, which marks values without options.
41
+ template(#tag="{ option, remove }")
42
+ span.multiselect__tag.dito-multiselect__tag(
43
+ :class=`{
44
+ 'dito-multiselect__tag--unavailable': isUnavailableOption(option)
45
+ }`
46
+ :title="isUnavailableOption(option) ? 'Not among the options' : null"
47
+ @mousedown.prevent
48
+ )
49
+ span {{ getLabelForOption(option) }}
50
+ i.multiselect__tag-icon(
51
+ tabindex="1"
52
+ @keydown.enter.prevent="remove(option)"
53
+ @mousedown.prevent="remove(option)"
54
+ )
40
55
  DitoAffixes(
41
56
  :items="schema.suffix"
42
57
  position="suffix"
@@ -70,7 +85,7 @@ import OptionsMixin from '../mixins/OptionsMixin.js'
70
85
  import DitoAffixes from '../components/DitoAffixes.vue'
71
86
  import VueMultiselect from 'vue-multiselect'
72
87
  import { getSchemaAccessor } from '../utils/accessor.js'
73
- import { isBoolean } from '@ditojs/utils'
88
+ import { isArray, isBoolean, isObject, isString } from '@ditojs/utils'
74
89
 
75
90
  // @vue/component
76
91
  export default DitoTypeComponent.register('multiselect', {
@@ -90,18 +105,22 @@ export default DitoTypeComponent.register('multiselect', {
90
105
  computed: {
91
106
  selectedOptions: {
92
107
  get() {
108
+ // Values without options stay visible once the options are loaded,
109
+ // and are kept when the selection changes, see `getFallbackOption()`,
110
+ // unless the multiselect is taggable and can add them as options.
93
111
  return this.multiple
94
- ? (this.selectedValue || [])
95
- .map(
96
- // If an option cannot be found, we may be in taggable mode and
97
- // can add it.
98
- value => (
99
- this.getOptionForValue(value) || this.addTagOption(value)
112
+ ? this.hasOptions && isArray(this.value)
113
+ ? this.value.map(value => {
114
+ const selectedValue = this.relate
115
+ ? this.getValueForOption(value)
116
+ : value
117
+ return (
118
+ this.getOptionForValue(selectedValue) ||
119
+ this.addTagOption(selectedValue) ||
120
+ this.getFallbackOption(value)
100
121
  )
101
- )
102
- // Filter out options that we couldn't match.
103
- // TODO: Should we display an error instead?
104
- .filter(Boolean)
122
+ })
123
+ : []
105
124
  : this.selectedOption
106
125
  },
107
126
 
@@ -196,6 +215,27 @@ export default DitoTypeComponent.register('multiselect', {
196
215
  }
197
216
  },
198
217
 
218
+ // Returns whether the option is a fallback option for a value without
219
+ // one, see `getFallbackOption()`. Plain options are their own values.
220
+ isUnavailableOption(option) {
221
+ return this.optionValue
222
+ ? !this.getOptionForValue(this.getValueForOption(option))
223
+ : !this.options.includes(option)
224
+ },
225
+
226
+ // Returns an option for a value without one, e.g. of options that only
227
+ // hold the results of searches: Related objects are their own options,
228
+ // other values get an option that holds them as value and label.
229
+ getFallbackOption(value) {
230
+ const { optionValue, optionLabel } = this
231
+ return isObject(value) || !isString(optionValue)
232
+ ? value
233
+ : {
234
+ [optionValue]: value,
235
+ ...(isString(optionLabel) && { [optionLabel]: `${value}` })
236
+ }
237
+ },
238
+
199
239
  focusElement() {
200
240
  this.$refs.element.activate()
201
241
  },
@@ -289,6 +329,16 @@ $tag-line-height: 1em;
289
329
  display: inline-flex;
290
330
  position: relative;
291
331
 
332
+ &__tag {
333
+ color: $color-text-inverted;
334
+ background: $color-active;
335
+
336
+ // Values without options, see `getFallbackOption()`.
337
+ &--unavailable {
338
+ background: $color-grey;
339
+ }
340
+ }
341
+
292
342
  &__inner {
293
343
  flex: 1;
294
344
  position: relative;
@@ -463,11 +513,6 @@ $tag-line-height: 1em;
463
513
  }
464
514
  }
465
515
 
466
- &__tag {
467
- color: $color-text-inverted;
468
- background: $color-active;
469
- }
470
-
471
516
  &__tag-icon {
472
517
  background: none;
473
518
  border-radius: 1em;
@@ -6,7 +6,9 @@
6
6
  :scopes="scopes"
7
7
  )
8
8
  .dito-tree-panel
9
+ //- Objects that are `null` have no tree, as tree items can't create them.
9
10
  DitoTreeItem(
11
+ v-if="treeData"
10
12
  :schema="treeSchema"
11
13
  :dataPath="treeDataPath"
12
14
  :data="treeData"
@@ -9,12 +9,13 @@ import {
9
9
  } from '@ditojs/utils'
10
10
  import DitoContext from '../DitoContext.js'
11
11
  import { DataSchemaResolver } from './DataSchemaResolver.js'
12
+ import { getParentDataPath } from './data.js'
12
13
  import { isNested } from './schema/structure.js'
13
14
  import { isEmptySchema } from './schema/lookup.js'
14
15
  import {
15
16
  getSchemaValue,
16
17
  shouldSetDefaultValue,
17
- getDefaultValue,
18
+ setDefaultValue,
18
19
  initializeData,
19
20
  computeValue,
20
21
  hasValueFromDataSchema,
@@ -41,7 +42,7 @@ import {
41
42
  // displays them, see `getOptions()`. Both get the same option objects, and
42
43
  // computes that read options only run once they are loaded.
43
44
  // - Submitting waits for the loads of data schemas and options that are still
44
- // pending, see `waitForPendingLoads()`.
45
+ // pending, see `waitUntilSettled()`.
45
46
  // - The data is dirty when its processed data differs from a snapshot taken
46
47
  // when the data was set up, saved or applied, see `isDirty`. The values that
47
48
  // the model derives until it settled, e.g. from loaded options, don't count,
@@ -68,7 +69,7 @@ export class DataModel {
68
69
  // of their options, by component path, see `getOptionsResolver()`:
69
70
  optionsRecords = new Map()
70
71
  // The promises of the loads of data schemas and options that are pending,
71
- // see `waitForPendingLoads()`:
72
+ // see `waitUntilSettled()`:
72
73
  pendingLoads = new Set()
73
74
  // The data that the snapshot was taken of, and its processed data to compare
74
75
  // with in `isDirty`, see `takeProcessedDataSnapshot()`:
@@ -77,6 +78,9 @@ export class DataModel {
77
78
  // the snapshot is taken, which `isDirty` takes from the current data, `null`
78
79
  // once it settled, see `takeProcessedDataSnapshot()`:
79
80
  derivedValueDataPaths = null
81
+ // The number of writes of computed values per data path in the current
82
+ // microtask, see `isWriteLoop()`:
83
+ computedValueWriteCounts = null
80
84
 
81
85
  // `getSchema()` and `getData()` return the schema and the data that the
82
86
  // model handles. `getSourceSchema()` returns the schema of the source that
@@ -166,12 +170,18 @@ export class DataModel {
166
170
  this.pendingLoads.clear()
167
171
  }
168
172
 
173
+ // Whether loads of data schemas or options are pending, see
174
+ // `waitUntilSettled()`.
175
+ get hasPendingLoads() {
176
+ return this.pendingLoads.size > 0
177
+ }
178
+
169
179
  // Waits for the pending loads of data schemas and options, including the
170
180
  // ones that they cause, e.g. options that depend on computed values that
171
181
  // depend on loaded options, and for the watchers to write the resulting
172
182
  // computed values into the data.
173
- async waitForPendingLoads() {
174
- while (this.pendingLoads.size > 0) {
183
+ async waitUntilSettled() {
184
+ while (this.hasPendingLoads) {
175
185
  await Promise.all(this.pendingLoads)
176
186
  await nextTick()
177
187
  }
@@ -180,7 +190,7 @@ export class DataModel {
180
190
  // Takes the snapshot of the processed data that `isDirty` compares with,
181
191
  // when the data is set up, saved or applied. Until the model settled, i.e.
182
192
  // the pending loads finished and the computed values that depend on them are
183
- // written into the data, see `waitForPendingLoads()`, the values that the
193
+ // written into the data, see `waitUntilSettled()`, the values that the
184
194
  // model writes are recorded in `derivedValueDataPaths` and don't count, as
185
195
  // they're derived from the data, e.g. from loaded options. Once settled, they
186
196
  // are taken over into the snapshot, so that changes made in the meantime,
@@ -200,7 +210,7 @@ export class DataModel {
200
210
  }
201
211
  // Let the watchers and the rendering start their loads first:
202
212
  await nextTick()
203
- await this.waitForPendingLoads()
213
+ await this.waitUntilSettled()
204
214
  // Snapshots that were taken since settle by themselves.
205
215
  const isSnapshotReplaced = (
206
216
  this.derivedValueDataPaths !==
@@ -265,7 +275,7 @@ export class DataModel {
265
275
  if (hasSnapshotOfData) {
266
276
  // Let the watchers write the values derived from the changes first:
267
277
  await nextTick()
268
- await this.waitForPendingLoads()
278
+ await this.waitUntilSettled()
269
279
  // Take the changes over into the current snapshot of the data, which may
270
280
  // have been replaced in the meantime, e.g. once the model settled.
271
281
  const processedDataSnapshot = this.processedDataSnapshot.value
@@ -283,7 +293,7 @@ export class DataModel {
283
293
  }
284
294
 
285
295
  // Tracks the promise of a pending load until it settles, so that
286
- // `waitForPendingLoads()` waits for it.
296
+ // `waitUntilSettled()` waits for it.
287
297
  trackPendingLoad(promise) {
288
298
  this.pendingLoads.add(promise)
289
299
  promise.finally(() => this.pendingLoads.delete(promise))
@@ -339,16 +349,23 @@ export class DataModel {
339
349
  // `false`. Unlike `shouldRenderSchema()`, it doesn't evaluate the components
340
350
  // of sections and tabs, as the walk of the data visits them anyway, see
341
351
  // `getDataEntries()`, which leads to the same entries.
352
+ // An `if` that fails only affects its own component, which is treated as
353
+ // shown, not the walk of all others, which would stop computing them.
342
354
  isEntryShown(entry) {
343
- return (
344
- entry.schema.if === undefined ||
345
- getSchemaValue('if', {
355
+ if (entry.schema.if === undefined) {
356
+ return true
357
+ }
358
+ try {
359
+ return getSchemaValue('if', {
346
360
  type: Boolean,
347
361
  schema: entry.schema,
348
362
  context: this.createEntryContext(entry),
349
363
  default: true
350
364
  })
351
- )
365
+ } catch (error) {
366
+ console.error(error)
367
+ return true
368
+ }
352
369
  }
353
370
 
354
371
  // Returns whether the value of the entry is missing and needs its default.
@@ -371,7 +388,7 @@ export class DataModel {
371
388
  for (const entry of entries) {
372
389
  if (this.shouldSetDefaultValue(entry)) {
373
390
  const { schema, data, name } = entry
374
- data[name] = getDefaultValue(schema, () =>
391
+ setDefaultValue(schema, data, name, () =>
375
392
  this.createEntryContext(entry)
376
393
  )
377
394
  }
@@ -532,18 +549,82 @@ export class DataModel {
532
549
  })
533
550
  }
534
551
 
552
+ // Writes the computed value into the data, with the defaults of its nested
553
+ // values, unless it equals the value in the data.
535
554
  writeComputedValue(entry, value) {
536
555
  const { data, name } = entry
556
+ this.setNestedDefaultValues(entry, value)
537
557
  if (!equals(value, data[name])) {
558
+ const dataPath = this.getRelativeDataPath(entry)
559
+ if (this.isWriteLoop(dataPath)) {
560
+ return
561
+ }
538
562
  // Values written while the model settles are derived, see
539
563
  // `takeProcessedDataSnapshot()`:
540
- this.derivedValueDataPaths?.add(this.getRelativeDataPath(entry))
564
+ this.derivedValueDataPaths?.add(dataPath)
541
565
  // Access `data[name]` directly to update the value without calling
542
566
  // `parse()`, see `ValueMixin`:
543
567
  data[name] = value
544
568
  }
545
569
  }
546
570
 
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
579
+ }
580
+ // Walk the computed value in place of the current one, in a copy of the
581
+ // data that holds it.
582
+ const valueData = { ...data, [name]: value }
583
+ processSchemaData({ components: { [name]: schema } }, valueData, {
584
+ dataPath: isNested(schema) ? getParentDataPath(dataPath) : dataPath,
585
+ shouldProcess: nestedEntry => this.isEntryShown(nestedEntry),
586
+ shouldSkipSourcesWithResource: true,
587
+ 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
+ )
598
+ }
599
+ },
600
+ options: { component: this.component, rootData: this.rootData }
601
+ })
602
+ }
603
+
604
+ // Returns whether the computed value at the data path was written too often
605
+ // in the current microtask, which happens when the sources of the value keep
606
+ // disagreeing on it, e.g. two computes. Reports the data path once, instead
607
+ // of letting the writes loop endlessly.
608
+ isWriteLoop(dataPath) {
609
+ if (!this.computedValueWriteCounts) {
610
+ this.computedValueWriteCounts = new Map()
611
+ queueMicrotask(() => {
612
+ this.computedValueWriteCounts = null
613
+ })
614
+ }
615
+ const count = (this.computedValueWriteCounts.get(dataPath) ?? 0) + 1
616
+ this.computedValueWriteCounts.set(dataPath, count)
617
+ if (count === maxComputedValueWrites + 1) {
618
+ console.error(
619
+ new Error(
620
+ `The computed value at '${dataPath}' keeps changing, as its ` +
621
+ `sources disagree on it. It isn't written anymore in this update.`
622
+ )
623
+ )
624
+ }
625
+ return count > maxComputedValueWrites
626
+ }
627
+
547
628
  // Returns the data path of the value of the entry, relative to the model's
548
629
  // data, as in its processed data.
549
630
  getRelativeDataPath({ schema, name, dataPath }) {
@@ -660,6 +741,10 @@ const modelWatchOptions = { immediate: true, flush: 'post' }
660
741
  // `DataModel.getComputedValueResult()`.
661
742
  const optionsNotLoaded = Symbol('optionsNotLoaded')
662
743
 
744
+ // The number of writes of a computed value in one microtask after which they
745
+ // are considered a loop, see `DataModel.isWriteLoop()`.
746
+ const maxComputedValueWrites = 10
747
+
663
748
  // Returns whether the schema is a source of computed values, through
664
749
  // `schema.compute()` or a data schema of the `computed` types.
665
750
  function hasComputedValueSource(schema) {
@@ -17,7 +17,7 @@ import {
17
17
  // e.g. `data: ({ item: { id } }) => async () => load(id)`, so that loading
18
18
  // only starts again when these dependencies change.
19
19
  // - `dataPath` is resolved relative to the data path of the context, in its
20
- // root data, e.g. `'../fontFamily/fontShopSets'`.
20
+ // root data, e.g. `'../author/books'`.
21
21
  // - Data schemas that aren't objects are values, e.g. `options: ['A', 'B']`.
22
22
  //
23
23
  // While a value is loading, `value` is `undefined` and `isLoading` is `true`.
@@ -44,8 +44,8 @@ const ditoOptionKeys = [
44
44
  'processSchema',
45
45
  'getPanelSchema',
46
46
  'getFormSchemasForProcessing',
47
- // Vue 3 / Vue-router 4 forgets these.
48
- // TODO: Create bug-report?
47
+ // vue-router reads route guards from the raw component options, without
48
+ // their mixins, see `extractComponentsGuards()` in its `navigationGuards.ts`.
49
49
  'beforeRouteUpdate',
50
50
  'beforeRouteLeave'
51
51
  ]
@@ -110,6 +110,17 @@ export function getDefaultValue(schema, context) {
110
110
  : clone(defaultValue)
111
111
  }
112
112
 
113
+ // Sets the value of the component described by `schema` and `name` in `data`
114
+ // to its default. Types without values, e.g. buttons, have no default, and
115
+ // don't get a key in the data, which would make the data differ from data
116
+ // without it, e.g. from items that `compute()` returns.
117
+ export function setDefaultValue(schema, data, name, context) {
118
+ const value = getDefaultValue(schema, context)
119
+ if (value !== undefined) {
120
+ data[name] = value
121
+ }
122
+ }
123
+
113
124
  export function shouldExcludeValue(schema, context) {
114
125
  const excludeValue =
115
126
  schema.exclude !== undefined
@@ -207,7 +218,7 @@ export function initializeData(schema, data = {}, component, {
207
218
  { shouldSetDefaultsOfComponentsWithCompute }
208
219
  )
209
220
  if (shouldSetDefault) {
210
- data[name] = getDefaultValue(schema, context)
221
+ setDefaultValue(schema, data, name, context)
211
222
  }
212
223
  if (hasItemsNumberedByOrderKey(schema) && isArray(data[name])) {
213
224
  updateOrder(schema, data[name])
package/types/index.d.ts CHANGED
@@ -3043,9 +3043,10 @@ interface TreeSchema<$Item, $Type extends 'tree-list' | 'tree-object'>
3043
3043
  name: string
3044
3044
  }
3045
3045
  /**
3046
- * Properties schema for tree nodes.
3046
+ * The properties shown for tree nodes, displayed like the columns of
3047
+ * lists.
3047
3048
  */
3048
- properties?: Record<string, Component<$Item>>
3049
+ properties?: Columns<$Item>
3049
3050
  /**
3050
3051
  * Whether child nodes are expanded by default.
3051
3052
  */