@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
@@ -1,4 +1,5 @@
1
1
  import ValidatorMixin from '../mixins/ValidatorMixin.js'
2
+ import { markRaw } from 'vue'
2
3
  import { getCommonPrefix } from '@ditojs/utils'
3
4
 
4
5
  // @vue/component
@@ -14,6 +15,9 @@ export default {
14
15
  data() {
15
16
  return {
16
17
  reload: false,
18
+ // The record of the route that this component renders, see the
19
+ // `matchedRouteRecord` watcher:
20
+ routeRecord: null,
17
21
  // Each route-component defines a store that gets passed on to its
18
22
  // child components, so they can store values in them that live beyond
19
23
  // their life-cycle. See: DitoPane, SourceMixin
@@ -37,10 +41,19 @@ export default {
37
41
  return level
38
42
  },
39
43
 
40
- routeRecord() {
44
+ // The record that the current route matches at this component's level,
45
+ // which is another record or `undefined` while the component is being left,
46
+ // until it's unmounted, see `routeRecord`.
47
+ matchedRouteRecord() {
41
48
  return this.$route.matched[this.routeLevel]
42
49
  },
43
50
 
51
+ // Whether the current route doesn't match the component's record anymore,
52
+ // so that the component is about to be unmounted.
53
+ isLeavingRoute() {
54
+ return this.matchedRouteRecord?.path !== this.routeRecord?.path
55
+ },
56
+
44
57
  isLastRoute() {
45
58
  // Returns true when this router component is the last one in the route.
46
59
  const { matched } = this.$route
@@ -106,6 +119,31 @@ export default {
106
119
  }
107
120
  },
108
121
 
122
+ watch: {
123
+ matchedRouteRecord: {
124
+ immediate: true,
125
+ // Update synchronously, so that the record changes with the route.
126
+ flush: 'sync',
127
+ handler(routeRecord) {
128
+ // Components belong to the record that they're rendered for, see
129
+ // `DitoRouterView`. Records of other paths, or none, mean that
130
+ // the component is being left, and it keeps its record until it's
131
+ // unmounted, as its schema and meta are still read in the meantime,
132
+ // e.g. by the watchers of its form model. Records of the same path
133
+ // replace it, e.g. when the routes are set up again.
134
+ const isOwnRouteRecord = (
135
+ !!routeRecord &&
136
+ (!this.routeRecord || routeRecord.path === this.routeRecord.path)
137
+ )
138
+ if (isOwnRouteRecord) {
139
+ // Keep the record raw, as it's compared with the records in
140
+ // `$route.matched`, which aren't reactive.
141
+ this.routeRecord = markRaw(routeRecord)
142
+ }
143
+ }
144
+ }
145
+ },
146
+
109
147
  beforeRouteUpdate(to, from) {
110
148
  return this?.beforeRouteChange(to, from)
111
149
  },
@@ -33,19 +33,6 @@ export default {
33
33
  if (oldIndex !== newIndex) {
34
34
  this.onChange()
35
35
  }
36
- },
37
-
38
- updateOrder(sourceSchema, list, paginationRange) {
39
- const { orderKey } = sourceSchema
40
- if (orderKey) {
41
- // Reorder the changed entries by their order key, taking pagination
42
- // offsets into account:
43
- const offset = paginationRange?.[0] || 0
44
- for (let i = 0; i < list.length; i++) {
45
- list[i][orderKey] = i + offset
46
- }
47
- }
48
- return list
49
36
  }
50
37
  }
51
38
  }
@@ -6,19 +6,21 @@ import { getSchemaAccessor, getStoreAccessor } from '../utils/accessor.js'
6
6
  import { getMemberResource } from '../utils/resource.js'
7
7
  import { replaceRoute } from '../utils/route.js'
8
8
  import {
9
- processRouteSchema,
10
- processForms,
9
+ isCompact,
10
+ isInlined,
11
+ isObjectSource,
12
+ isListSource
13
+ } from '../utils/schema/structure.js'
14
+ import { setupRouteSchema, setupForms } from '../utils/schema/setup.js'
15
+ import {
11
16
  getNamedSchemas,
12
17
  getButtonSchemas,
13
18
  hasFormSchema,
14
19
  getFormSchemas,
15
20
  getViewSchema,
16
- getViewPath,
17
- isCompact,
18
- isInlined,
19
- isObjectSource,
20
- isListSource
21
- } from '../utils/schema.js'
21
+ getViewPath
22
+ } from '../utils/schema/lookup.js'
23
+ import { updateOrder } from '../utils/schema/data.js'
22
24
  import {
23
25
  isObject,
24
26
  isString,
@@ -28,7 +30,6 @@ import {
28
30
  parseDataPath,
29
31
  normalizeDataPath
30
32
  } from '@ditojs/utils'
31
- import { raw } from '@ditojs/ui'
32
33
 
33
34
  // @vue/component
34
35
  export default {
@@ -46,8 +47,7 @@ export default {
46
47
 
47
48
  data() {
48
49
  return {
49
- wrappedPrimitives: null,
50
- unwrappingPrimitives: raw(false)
50
+ wrappedPrimitives: null
51
51
  }
52
52
  },
53
53
 
@@ -90,39 +90,27 @@ export default {
90
90
  return this.schema.wrapPrimitives
91
91
  },
92
92
 
93
+ valueListData() {
94
+ // The list of items held by `value`, before wrapping primitives.
95
+ const { value } = this
96
+ return this.isObjectSource
97
+ ? value != null
98
+ ? [value]
99
+ : []
100
+ : (this.isListResults(value) ? value.results : value) || []
101
+ },
102
+
103
+ primitiveValues() {
104
+ // The values of `wrapPrimitives` lists, copied so that changes of their
105
+ // entries are tracked too, see the `primitiveValues` watcher.
106
+ return this.wrapPrimitives ? [...this.valueListData] : null
107
+ },
108
+
93
109
  listData: {
94
110
  get() {
95
- let data = this.value
96
- if (this.isObjectSource) {
97
- // Convert to list array.
98
- data = data != null ? [data] : []
99
- } else {
100
- // If data gets inherited from parent, unwrapping is not happening
101
- // at the root in `setData()`, but here instead.
102
- data = this.unwrapListData(data) || data
103
- }
104
- data ||= []
105
- const { wrapPrimitives } = this
106
- if (wrapPrimitives) {
107
- if (this.unwrappingPrimitives.value) {
108
- // We're done unwrapping once `listData` is reevaluated, so set
109
- // this to `false` again. See `wrappedPrimitives` watcher above.
110
- // TODO: Fix side-effects
111
- // eslint-disable-next-line max-len
112
- // eslint-disable-next-line vue/no-side-effects-in-computed-properties
113
- this.unwrappingPrimitives.value = false
114
- } else {
115
- // Convert data to a list of wrapped primitives, and return it.
116
- // TODO: Fix side-effects
117
- // eslint-disable-next-line max-len
118
- // eslint-disable-next-line vue/no-side-effects-in-computed-properties
119
- this.wrappedPrimitives = data.map(value => ({
120
- [wrapPrimitives]: value
121
- }))
122
- }
123
- return this.wrappedPrimitives
124
- }
125
- return data
111
+ return this.wrapPrimitives
112
+ ? this.wrappedPrimitives || []
113
+ : this.valueListData
126
114
  },
127
115
 
128
116
  set(data) {
@@ -133,7 +121,7 @@ export default {
133
121
  ? data?.length > 0
134
122
  ? data[0]
135
123
  : null
136
- : data
124
+ : updateOrder(this.sourceSchema, data, this.paginationRange)
137
125
  }
138
126
  }
139
127
  },
@@ -289,6 +277,11 @@ export default {
289
277
  default: null
290
278
  }),
291
279
 
280
+ hasItems() {
281
+ // Object sources only have an item to edit or delete once they're set.
282
+ return this.isListSource || !!this.value
283
+ },
284
+
292
285
  creatable: getSchemaAccessor('creatable', {
293
286
  type: Boolean,
294
287
  default: false,
@@ -305,13 +298,16 @@ export default {
305
298
  type: Boolean,
306
299
  default: false,
307
300
  get(editable) {
308
- return editable && !this.isInlined
301
+ return editable && !this.isInlined && this.hasItems
309
302
  }
310
303
  }),
311
304
 
312
305
  deletable: getSchemaAccessor('deletable', {
313
306
  type: Boolean,
314
- default: false
307
+ default: false,
308
+ get(deletable) {
309
+ return deletable && this.hasItems
310
+ }
315
311
  }),
316
312
 
317
313
  draggable: getSchemaAccessor('draggable', {
@@ -382,18 +378,38 @@ export default {
382
378
  }
383
379
  },
384
380
 
381
+ value: {
382
+ immediate: true,
383
+ handler(value) {
384
+ // If data gets inherited from parent, unwrapping `{ results, total }`
385
+ // isn't happening at the root in `setData()`, but here instead.
386
+ if (this.isListResults(value)) {
387
+ this.unwrapListData(value)
388
+ }
389
+ }
390
+ },
391
+
392
+ primitiveValues: {
393
+ immediate: true,
394
+ handler(values) {
395
+ // Wrap the primitive values in objects for the list's forms to edit,
396
+ // unless they're the values that were just unwrapped from them.
397
+ if (values && !equals(values, this.unwrapPrimitives())) {
398
+ const { wrapPrimitives } = this
399
+ this.wrappedPrimitives = values.map(value => ({
400
+ [wrapPrimitives]: value
401
+ }))
402
+ }
403
+ }
404
+ },
405
+
385
406
  wrappedPrimitives: {
386
407
  deep: true,
387
- handler(to, from) {
388
- const { wrapPrimitives } = this
389
- // Skip the initial setting of wrappedPrimitives array
390
- if (wrapPrimitives && from !== null) {
391
- // Whenever the wrappedPrimitives change, map their values back to the
392
- // array of primitives, in a primitive way :)
393
- // But set `unwrappingPrimitives` to true, so the `listData` computed
394
- // property knows about it, which sets it to `false` again.
395
- this.unwrappingPrimitives.value = true
396
- this.value = to.map(object => object[wrapPrimitives])
408
+ handler() {
409
+ // Map edits of the wrapped primitives back to the primitive values.
410
+ const values = this.unwrapPrimitives()
411
+ if (values && !equals(values, this.primitiveValues)) {
412
+ this.value = values
397
413
  }
398
414
  }
399
415
  }
@@ -418,11 +434,9 @@ export default {
418
434
  // Support two formats for list data:
419
435
  // - Array: `[...]`
420
436
  // - Object: `{ results: [...], total }`, see `unwrapListData()`
421
- if (
422
- !data ||
423
- this.isListSource && isArray(data) ||
424
- this.isObjectSource && isObject(data)
425
- ) {
437
+ if (this.isListSource && isArray(data)) {
438
+ this.setLoadedListItems(data)
439
+ } else if (!data || this.isObjectSource && isObject(data)) {
426
440
  this.value = data
427
441
  } else if (this.unwrapListData(data)) {
428
442
  // The format didn't match, see if we received a `{ results, total }`
@@ -431,33 +445,60 @@ export default {
431
445
  } else if (isObject(data) && this.isInView) {
432
446
  // The controller is sending data for a full multi-component view,
433
447
  // including the nested list data.
434
- this.viewComponent.setData(data)
448
+ return this.viewComponent.setData(data)
435
449
  }
450
+ return this.value
436
451
  },
437
452
 
438
- unwrapListData(data) {
439
- if (
453
+ isListResults(data) {
454
+ // Lists can also be loaded as `{ results, total }`, see `setData()`.
455
+ return (
440
456
  this.isListSource &&
441
457
  isObject(data) &&
442
458
  isNumber(data.total) &&
443
459
  isArray(data.results)
444
- ) {
460
+ )
461
+ },
462
+
463
+ unwrapPrimitives() {
464
+ const { wrapPrimitives, wrappedPrimitives } = this
465
+ return wrapPrimitives && wrappedPrimitives
466
+ ? wrappedPrimitives.map(object => object[wrapPrimitives])
467
+ : null
468
+ },
469
+
470
+ unwrapListData(data) {
471
+ if (this.isListResults(data)) {
445
472
  // If @ditojs/server sends data in the form of `{ results, total }`
446
473
  // replace the value with result, but remember the total in the store.
447
474
  this.total = data.total
448
- this.value = data.results
475
+ this.setLoadedListItems(data.results)
449
476
  return this.value
450
477
  }
451
478
  },
452
479
 
480
+ // Sets the list items loaded through the source's resource, numbered by
481
+ // their order key with the offset of the loaded page, so that their order
482
+ // can be stored even if it never changes, e.g. by a button that saves the
483
+ // current order. Lists in the data of forms, views and dialogs are
484
+ // numbered by `FormModel`, see `initializeData()`.
485
+ setLoadedListItems(items) {
486
+ this.value = updateOrder(this.sourceSchema, items, this.paginationRange)
487
+ },
488
+
453
489
  createItem(schema, type, index = null) {
454
490
  const item = this.createData(schema, type)
455
491
  if (this.isObjectSource) {
456
492
  this.objectData = item
457
- } else if (index != null) {
458
- this.listData.splice(index, 0, item)
459
493
  } else {
460
- this.listData.push(item)
494
+ const { listData } = this
495
+ if (index != null) {
496
+ listData.splice(index, 0, item)
497
+ } else {
498
+ listData.push(item)
499
+ }
500
+ // Set the list back, as `listData` is a new array for `null` values.
501
+ this.listData = listData
461
502
  }
462
503
  if (this.collapsible) {
463
504
  this.$nextTick(() => this.openSchemaComponent(index ?? -1))
@@ -475,6 +516,8 @@ export default {
475
516
  const { listData } = this
476
517
  if (index >= 0) {
477
518
  listData.splice(index, 1)
519
+ // Set the list back, to update the order of the remaining items.
520
+ this.listData = listData
478
521
  removed = true
479
522
  }
480
523
  }
@@ -556,31 +599,34 @@ export default {
556
599
  }
557
600
  },
558
601
 
559
- async navigateToComponent(dataPath, onComplete) {
560
- if (this.collapsible) {
561
- const index = dataPath.startsWith(this.dataPath)
562
- ? this.isListSource
563
- ? parseDataPath(dataPath.slice(this.dataPath.length + 1))[0] ?? null
564
- : 0
565
- : null
566
- if (index !== null && isNumber(+index)) {
567
- const schemaComponent = this.getSchemaComponent(+index)
568
- if (schemaComponent) {
569
- const { opened } = schemaComponent
570
- if (!opened) {
571
- schemaComponent.opened = true
572
- await this.$nextTick()
573
- }
574
- const components = schemaComponent.getComponentsByDataPath(dataPath)
575
- if (components.length > 0 && (onComplete?.(components) ?? true)) {
576
- return true
577
- } else {
578
- schemaComponent.opened = opened
579
- }
580
- }
602
+ // See `DitoSchema.navigateToComponent()`, and `shouldRevealInlinedOnly`
603
+ // there.
604
+ async navigateToComponent(
605
+ dataPath,
606
+ onComplete,
607
+ { shouldRevealInlinedOnly = false } = {}
608
+ ) {
609
+ const index = dataPath.startsWith(this.dataPath)
610
+ ? this.isListSource
611
+ ? parseDataPath(dataPath.slice(this.dataPath.length + 1))[0] ?? null
612
+ : 0
613
+ : null
614
+ if (index !== null && isNumber(+index)) {
615
+ // Inlined items reveal the component themselves, opening collapsed
616
+ // items and sections, see `DitoSchema.navigateToComponent()`.
617
+ const schemaComponent = this.getSchemaComponent(+index)
618
+ if (
619
+ await schemaComponent?.navigateToComponent(dataPath, onComplete, {
620
+ shouldRevealInlinedOnly
621
+ })
622
+ ) {
623
+ return true
581
624
  }
582
625
  }
583
- return this.navigateToRouteComponent(dataPath, onComplete)
626
+ return (
627
+ !shouldRevealInlinedOnly &&
628
+ this.navigateToRouteComponent(dataPath, onComplete)
629
+ )
584
630
  },
585
631
 
586
632
  navigateToRouteComponent(dataPath, onComplete) {
@@ -637,7 +683,7 @@ export default {
637
683
  flatten = false,
638
684
  process = null
639
685
  ) {
640
- processRouteSchema(api, schema, name)
686
+ setupRouteSchema(api, schema, name)
641
687
  const inlined = isInlined(schema)
642
688
  if (inlined && schema.resource) {
643
689
  throw new Error(
@@ -666,7 +712,7 @@ export default {
666
712
  nested,
667
713
  param
668
714
  }
669
- const childRoutes = await processForms(api, schema, level)
715
+ const childRoutes = await setupForms(api, schema, level)
670
716
  if (process) {
671
717
  await process(childRoutes, level + 1)
672
718
  }
@@ -695,8 +741,9 @@ export default {
695
741
  path: getPathWithParam(sourcePath, param)
696
742
  })
697
743
  }
698
- if (sourcePath) {
699
- // Just redirect back to the parent when a nested source route is hit.
744
+ if (sourcePath && isListSource(schema)) {
745
+ // Just redirect back to the parent when a nested list route is hit.
746
+ // Object sources use this path for their form route instead.
700
747
  routes.push({
701
748
  path: sourcePath,
702
749
  redirect: '.',
@@ -734,10 +781,22 @@ export default {
734
781
 
735
782
  processValue({ schema, value, dataPath }, graph) {
736
783
  graph.addSource(dataPath, schema)
737
- return value
784
+ // `SchemaGraph.process()` changes the ids of the items in the processed
785
+ // data. The items of sources with forms are processed into copies, but the
786
+ // ones of sources without forms are still the items of the data.
787
+ return hasFormSchema(schema) ? value : copyItems(value)
738
788
  }
739
789
  }
740
790
 
791
+ // Returns shallow copies of the items of a list, or of an object.
792
+ function copyItems(value) {
793
+ return isArray(value)
794
+ ? value.map(item => (isObject(item) ? { ...item } : item))
795
+ : isObject(value)
796
+ ? { ...value }
797
+ : value
798
+ }
799
+
741
800
  function getPathWithParam(path, param) {
742
801
  return param
743
802
  ? path
@@ -1,5 +1,5 @@
1
1
  import { isString } from '@ditojs/utils'
2
- import { getDefaultValue } from '../utils/schema.js'
2
+ import { getDefaultValue } from '../utils/schema/data.js'
3
3
  import { getSchemaAccessor } from '../utils/accessor.js'
4
4
 
5
5
  // @vue/component
@@ -2,7 +2,8 @@ import ValueMixin from './ValueMixin.js'
2
2
  import ContextMixin from './ContextMixin.js'
3
3
  import ValidationMixin from './ValidationMixin.js'
4
4
  import { getSchemaAccessor } from '../utils/accessor.js'
5
- import { asArray, camelize } from '@ditojs/utils'
5
+ import { getValidations } from '../utils/schema/validation.js'
6
+ import { asArray, camelize, equals } from '@ditojs/utils'
6
7
 
7
8
  // @vue/component
8
9
  export default {
@@ -131,7 +132,8 @@ export default {
131
132
 
132
133
  const attributes = {
133
134
  ...this.events,
134
- disabled: this.disabled
135
+ // Only text fields support `readonly`, disable all other components.
136
+ disabled: this.disabled || (this.readonly && !textField)
135
137
  }
136
138
 
137
139
  if (nativeField) {
@@ -152,19 +154,7 @@ export default {
152
154
  },
153
155
 
154
156
  validations() {
155
- const validations = { ...this.getValidations() }
156
- if (this.required) {
157
- validations.required = true
158
- }
159
- // Allow schema to override default rules and add any new ones:
160
- for (const [key, value] of Object.entries(this.schema.rules || {})) {
161
- if (value === undefined) {
162
- delete validations[key]
163
- } else {
164
- validations[key] = value
165
- }
166
- }
167
- return validations
157
+ return getValidations(this.schema, this.context)
168
158
  },
169
159
 
170
160
  showClearButton() {
@@ -172,6 +162,25 @@ export default {
172
162
  }
173
163
  },
174
164
 
165
+ watch: {
166
+ value(value) {
167
+ // Displayed errors refer to the value that they were added for, see
168
+ // `addError()`, so they're cleared when the value changes, whether
169
+ // through input or by code, e.g. by a button that generates a value.
170
+ // Errors that were added for the new value already, e.g. by `validate()`
171
+ // on blur right after the change, are kept.
172
+ if (this.hasErrors && !equals(value, this.valueWithErrors)) {
173
+ this.clearErrors()
174
+ }
175
+ },
176
+
177
+ componentPath(componentPath, oldComponentPath) {
178
+ // Component paths change when list items move, see `_registerEntry()`.
179
+ this._registerWithSchemaComponent(false, oldComponentPath)
180
+ this._registerWithSchemaComponent(true, componentPath)
181
+ }
182
+ },
183
+
175
184
  created() {
176
185
  this._register(true)
177
186
  this.setupSchemaFields()
@@ -185,9 +194,20 @@ export default {
185
194
  _register(add) {
186
195
  // Provide component to container for schema accessor evaluation.
187
196
  this.$emit('update:component', add ? this : null)
188
- // Prevent unnested type components from overriding parent data paths
197
+ this._registerWithSchemaComponent(add, this.componentPath)
198
+ },
199
+
200
+ _registerWithSchemaComponent(add, componentPath) {
201
+ // Register unnested type components separately, to prevent them from
202
+ // overriding parent data paths.
189
203
  if (this.nested) {
190
- this.schemaComponent._registerComponent(this, add)
204
+ this.schemaComponent._registerComponent(this, add, componentPath)
205
+ } else {
206
+ this.schemaComponent?._registerUnnestedComponent(
207
+ this,
208
+ add,
209
+ componentPath
210
+ )
191
211
  }
192
212
  },
193
213
 
@@ -197,11 +217,6 @@ export default {
197
217
  return { onFocus, onBlur, onInput, onChange }
198
218
  },
199
219
 
200
- // @overridable
201
- getValidations() {
202
- return null
203
- },
204
-
205
220
  // @overridable
206
221
  async scrollIntoView() {
207
222
  await this.focusSchema()
@@ -1,5 +1,4 @@
1
- import { isFunction } from '@ditojs/utils'
2
- import * as validators from '../validators/index.js'
1
+ import { getValueValidationErrors } from '../utils/schema/validation.js'
3
2
 
4
3
  // @vue/component
5
4
  export default {
@@ -11,7 +10,9 @@ export default {
11
10
  isDirty: false,
12
11
  isValidated: false,
13
12
  isValid: false,
14
- errors: null
13
+ errors: null,
14
+ // The value that the displayed errors were added for, see `addError()`.
15
+ valueWithErrors: undefined
15
16
  }
16
17
  },
17
18
 
@@ -31,33 +32,13 @@ export default {
31
32
  },
32
33
 
33
34
  validate(notify = true) {
34
- let isValid = true
35
+ const errors = getValueValidationErrors(this.value, this.validations)
36
+ const isValid = errors.length === 0
35
37
  if (notify) {
36
38
  this.clearErrors()
37
- }
38
- const { value } = this
39
- for (const [rule, setting] of Object.entries(this.validations)) {
40
- // eslint-disable-next-line import/namespace
41
- const validator = validators[rule]
42
- if (
43
- validator &&
44
- // Only apply 'required' validator to empty values.
45
- // Apply all other validators only to non-empty values.
46
- (rule === 'required' || value != null && value !== '')
47
- ) {
48
- const { validate, message } = validator
49
- if (!validate(value, setting, this.validations)) {
50
- isValid = false
51
- if (notify) {
52
- const error = isFunction(message)
53
- ? message(value, setting, this)
54
- : message
55
- this.addError(error, true)
56
- }
57
- }
39
+ for (const { message } of errors) {
40
+ this.addError(message, true)
58
41
  }
59
- }
60
- if (notify) {
61
42
  this.isValidated = true
62
43
  this.isValid = isValid
63
44
  }
@@ -84,6 +65,7 @@ export default {
84
65
 
85
66
  addError(error, addLabel = false) {
86
67
  this.errors ||= []
68
+ this.valueWithErrors = this.value
87
69
  if (addLabel) {
88
70
  const label = this.label || this.placeholder || this.name
89
71
  error = `The ${label} field ${error}.`
@@ -43,14 +43,12 @@ export default {
43
43
  this.schemaComponents.forEach(it => it.clearErrors())
44
44
  },
45
45
 
46
- showValidationErrors(errors, focus, first = true) {
47
- this.schemaComponents.forEach(
48
- schemaComponent => {
49
- if (schemaComponent.showValidationErrors(errors, focus, first)) {
50
- first = false
51
- }
46
+ async showValidationErrors(errors, focus, first = true) {
47
+ for (const schemaComponent of this.schemaComponents) {
48
+ if (await schemaComponent.showValidationErrors(errors, focus, first)) {
49
+ first = false
52
50
  }
53
- )
51
+ }
54
52
  return !first
55
53
  }
56
54
  }