@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
@@ -28,7 +28,6 @@ slot(name="prepend")
28
28
  v-if="hasLabel"
29
29
  :label="label"
30
30
  :info="info"
31
- :dataPath="dataPath"
32
31
  :collapsible="collapsible"
33
32
  :collapsed="!opened"
34
33
  @open="onOpen"
@@ -40,8 +39,7 @@ slot(name="prepend")
40
39
  DitoTabs(
41
40
  v-if="opened"
42
41
  v-model="selectedTab"
43
- :tabs="tabs"
44
- :dataPath="dataPath"
42
+ :tabs="renderedTabs"
45
43
  )
46
44
  DitoClipboard(
47
45
  v-if="clipboard"
@@ -62,8 +60,10 @@ slot(name="prepend")
62
60
  v-for="(tabSchema, tab) in tabs"
63
61
  :key="tab"
64
62
  )
65
- //- TODO: Switch to v-if instead of v-show, once validation is
66
- //- decoupled from components.
63
+ //- TODO: Switch to v-if instead of v-show, now that the dirty state
64
+ //- of forms is based on their data (views still use the dirty flags
65
+ //- of their components). `navigateToComponent()` then needs to
66
+ //- select the tab that displays the component.
67
67
  DitoPane.dito-pane__tab(
68
68
  v-show="selectedTab === tab"
69
69
  ref="tabs"
@@ -111,7 +111,6 @@ import {
111
111
  isObject,
112
112
  isArray,
113
113
  isFunction,
114
- isRegExp,
115
114
  equals,
116
115
  parseDataPath,
117
116
  normalizeDataPath,
@@ -122,14 +121,17 @@ import DitoComponent from '../DitoComponent.js'
122
121
  import ContextMixin from '../mixins/ContextMixin.js'
123
122
  import ItemMixin from '../mixins/ItemMixin.js'
124
123
  import { appendDataPath } from '../utils/data.js'
124
+ import { isPanel } from '../utils/schema/structure.js'
125
125
  import {
126
126
  getNamedSchemas,
127
127
  getPanelEntries,
128
- setDefaultValues,
129
- processData,
130
- isEmptySchema,
131
- isNested
132
- } from '../utils/schema.js'
128
+ isEmptySchema
129
+ } from '../utils/schema/lookup.js'
130
+ import { initializeData, processData } from '../utils/schema/data.js'
131
+ import {
132
+ getDataValidationErrors,
133
+ getMatchingValidationErrors
134
+ } from '../utils/schema/validation.js'
133
135
  import { getSchemaAccessor, getStoreAccessor } from '../utils/accessor.js'
134
136
 
135
137
  // @vue/component
@@ -185,6 +187,7 @@ export default DitoComponent.component('DitoSchema', {
185
187
  ),
186
188
  selectedTab: null,
187
189
  componentsRegistry: {},
190
+ unnestedComponentsRegistry: {},
188
191
  panesRegistry: {},
189
192
  panelsRegistry: {},
190
193
  scrollPositions: {}
@@ -210,17 +213,33 @@ export default DitoComponent.component('DitoSchema', {
210
213
  },
211
214
 
212
215
  panelEntries() {
213
- return getPanelEntries(this.schema.panels, this.dataPath)
216
+ return getPanelEntries(
217
+ this.schema.panels,
218
+ this.dataPath,
219
+ this.componentPath
220
+ )
214
221
  },
215
222
 
216
223
  tabs() {
217
224
  return getNamedSchemas(this.schema.tabs)
218
225
  },
219
226
 
227
+ renderedTabs() {
228
+ // Evaluate the tabs' `if` with the schema's context, and only once there
229
+ // is data to evaluate it with, as their components do too.
230
+ return this.tabs && this.data
231
+ ? Object.fromEntries(
232
+ Object.entries(this.tabs).filter(([, tab]) =>
233
+ this.shouldRenderSchema(tab)
234
+ )
235
+ )
236
+ : this.tabs
237
+ },
238
+
220
239
  defaultTab() {
221
240
  let first = null
222
- if (this.tabs) {
223
- const tabs = Object.values(this.tabs).filter(this.shouldRenderSchema)
241
+ if (this.renderedTabs) {
242
+ const tabs = Object.values(this.renderedTabs)
224
243
  for (const { name, defaultTab } of tabs) {
225
244
  if (isFunction(defaultTab) ? defaultTab(this.context) : defaultTab) {
226
245
  return name
@@ -251,13 +270,11 @@ export default DitoComponent.component('DitoSchema', {
251
270
 
252
271
  // @override
253
272
  processedData() {
254
- // TODO: Fix side-effects
255
273
  return this.processData({ target: 'server', schemaOnly: true })
256
274
  },
257
275
 
258
276
  clipboardData: {
259
277
  get() {
260
- // TODO: Fix side-effects
261
278
  return this.processData({ target: 'clipboard', schemaOnly: true })
262
279
  },
263
280
 
@@ -276,10 +293,6 @@ export default DitoComponent.component('DitoSchema', {
276
293
  )
277
294
  },
278
295
 
279
- isNested() {
280
- return isNested(this.schema)
281
- },
282
-
283
296
  isDirty() {
284
297
  return this.someComponent(it => it.isDirty)
285
298
  },
@@ -313,7 +326,8 @@ export default DitoComponent.component('DitoSchema', {
313
326
  },
314
327
 
315
328
  isTopLevelSchema() {
316
- return !this.isNested && !this.inlined
329
+ // Panels are displayed in the sidebar, inlined schemas in their parents.
330
+ return !isPanel(this.schema) && !this.inlined
317
331
  },
318
332
 
319
333
  hasTopLevelTabs() {
@@ -335,6 +349,10 @@ export default DitoComponent.component('DitoSchema', {
335
349
  return Object.values(this.componentsRegistry)
336
350
  },
337
351
 
352
+ unnestedComponents() {
353
+ return Object.values(this.unnestedComponentsRegistry)
354
+ },
355
+
338
356
  panes() {
339
357
  return Object.values(this.panesRegistry)
340
358
  },
@@ -396,9 +414,11 @@ export default DitoComponent.component('DitoSchema', {
396
414
  })
397
415
  }
398
416
  if (this.hasTopLevelTabs) {
399
- const tab = this.shouldRenderSchema(this.tabs[newTab])
400
- ? newTab
401
- : this.defaultTab
417
+ // Without a selected tab, e.g. while a nested form is open, keep the
418
+ // route without hash. Fall back to the default tab only for tabs that
419
+ // aren't rendered.
420
+ const tab =
421
+ !newTab || newTab in this.renderedTabs ? newTab : this.defaultTab
402
422
  this.$router.replace({
403
423
  query: this.$route.query,
404
424
  hash: tab ? `#${tab}` : null
@@ -487,16 +507,7 @@ export default DitoComponent.component('DitoSchema', {
487
507
 
488
508
  onOpen(open) {
489
509
  this.emitEvent('open', { context: { open } })
490
- // Prevent closing the schema with invalid data, since the in-component
491
- // validation will not be executed once it's closed.
492
-
493
- // TODO: Move validation out of components, to schema, just like
494
- // processing, and use `showValidationErrors()` for the resulting errors,
495
- // then remove this requirement, since we can validate closed forms and
496
- // schemas then.
497
- if (!this.opened || open || this.validateAll()) {
498
- this.opened = open
499
- }
510
+ this.opened = open
500
511
  },
501
512
 
502
513
  onChange() {
@@ -532,43 +543,30 @@ export default DitoComponent.component('DitoSchema', {
532
543
  },
533
544
 
534
545
  validateAll(match, notify = true) {
535
- const { componentsByDataPath } = this
536
- let dataPaths
537
- if (match) {
538
- const check = isFunction(match)
539
- ? match
540
- : isRegExp(match)
541
- ? field => match.test(field)
542
- : null
543
- dataPaths = check
544
- ? Object.keys(componentsByDataPath).filter(check)
545
- : isArray(match)
546
- ? match
547
- : [match]
548
- }
546
+ // Validate the data, not the mounted components, so that fields in
547
+ // collapsed schemas and closed nested forms are validated too.
548
+ const dataValidationErrors = this.hasData
549
+ ? getDataValidationErrors(this.schema, this.data, {
550
+ dataPath: this.dataPath,
551
+ component: this,
552
+ rootData: this.rootData
553
+ })
554
+ : null
555
+ const matchingValidationErrors = match
556
+ ? getMatchingValidationErrors(dataValidationErrors, match)
557
+ : dataValidationErrors
549
558
  if (notify) {
550
- this.clearErrors()
551
- }
552
- let isValid = true
553
- let first = true
554
- dataPaths ||= Object.keys(componentsByDataPath)
555
- for (const dataPath of dataPaths) {
556
- const components = this.getComponentsByDataPath(dataPath)
557
- for (const component of components) {
558
- if (!component.validate(notify)) {
559
- // Focus first error field
560
- if (notify && first) {
561
- component.scrollIntoView()
562
- }
563
- first = false
564
- isValid = false
565
- }
559
+ if (matchingValidationErrors) {
560
+ this._showValidationErrorsByFullDataPath(
561
+ matchingValidationErrors,
562
+ true,
563
+ true
564
+ ).catch(console.error)
565
+ } else {
566
+ this.clearErrors()
566
567
  }
567
568
  }
568
- if (notify && !isValid) {
569
- this.notifyErrors()
570
- }
571
- return isValid
569
+ return !matchingValidationErrors
572
570
  },
573
571
 
574
572
  verifyAll(match) {
@@ -576,75 +574,162 @@ export default DitoComponent.component('DitoSchema', {
576
574
  },
577
575
 
578
576
  async showValidationErrors(errors, focus, first = true) {
577
+ // Convert the data paths from JavaScript property access notation to
578
+ // our own form of relative JSON pointers. If the schema is a data-root,
579
+ // prefix its own dataPath to all errors, since the data that it sends
580
+ // and validates will be unprefixed.
581
+ const errorsByFullDataPath = Object.fromEntries(
582
+ Object.entries(errors).map(([dataPath, errs]) => [
583
+ normalizeDataPath(
584
+ this.hasOwnData
585
+ ? appendDataPath(this.dataPath, dataPath)
586
+ : dataPath
587
+ ),
588
+ errs
589
+ ])
590
+ )
591
+ return this._showValidationErrorsByFullDataPath(
592
+ errorsByFullDataPath,
593
+ focus,
594
+ first
595
+ )
596
+ },
597
+
598
+ // Finds all components that display the value at `dataPath` and passes
599
+ // them to `onComplete()`, revealing them first if needed: Collapsed
600
+ // schemas open, and the nearest components that display a part of the
601
+ // data path are asked to navigate to the rest, e.g. sections open and
602
+ // sources open their inlined items or navigate to nested forms, see
603
+ // `SourceMixin.navigateToComponent()`. Once components are found, the
604
+ // other ones are only revealed in inlined schemas
605
+ // (`shouldRevealInlinedOnly`), so that there's only one navigation to a
606
+ // nested form. Returns `true` if components were found and `onComplete()`
607
+ // accepted them.
608
+ async navigateToComponent(
609
+ dataPath,
610
+ onComplete,
611
+ { shouldRevealInlinedOnly = false } = {}
612
+ ) {
613
+ const { opened } = this
614
+ if (!opened) {
615
+ this.opened = true
616
+ await this.$nextTick()
617
+ }
618
+ const acceptedComponents = []
619
+ const completeWithComponents = components => {
620
+ const isAccepted = onComplete?.(components) ?? true
621
+ if (isAccepted) {
622
+ acceptedComponents.push(...components)
623
+ }
624
+ return isAccepted
625
+ }
626
+
627
+ const navigateViaNestedComponents = async nestedComponents => {
628
+ for (const nestedComponent of nestedComponents) {
629
+ // Skip components that contain accepted components already.
630
+ const componentPathPrefix = `${nestedComponent.componentPath}/`
631
+ const hasAcceptedComponents = acceptedComponents.some(
632
+ acceptedComponent =>
633
+ acceptedComponent.componentPath.startsWith(componentPathPrefix)
634
+ )
635
+ if (!hasAcceptedComponents) {
636
+ await nestedComponent.navigateToComponent?.(
637
+ dataPath,
638
+ completeWithComponents,
639
+ {
640
+ shouldRevealInlinedOnly: (
641
+ shouldRevealInlinedOnly || acceptedComponents.length > 0
642
+ )
643
+ }
644
+ )
645
+ }
646
+ }
647
+ }
648
+
649
+ const displayingComponents = this.getComponentsByDataPath(dataPath)
650
+ if (displayingComponents.length > 0) {
651
+ completeWithComponents(displayingComponents)
652
+ }
653
+ // Walk up the data path and ask the nested components that display a
654
+ // part of it to navigate to the rest of it:
655
+ const dataPathParts = parseDataPath(dataPath)
656
+ const schemaDataPathLength = parseDataPath(this.dataPath).length
657
+ while (dataPathParts.length > schemaDataPathLength + 1) {
658
+ dataPathParts.pop()
659
+ await navigateViaNestedComponents(
660
+ this.getComponentsByDataPath(dataPathParts)
661
+ )
662
+ }
663
+ // Unnested components share the data path of the schema, so they
664
+ // decide by schema whether they can navigate to the data path:
665
+ await navigateViaNestedComponents(this.unnestedComponents)
666
+ const hasFoundComponents = acceptedComponents.length > 0
667
+ if (!hasFoundComponents) {
668
+ this.opened = opened
669
+ }
670
+ return hasFoundComponents
671
+ },
672
+
673
+ // Shows `errorsByFullDataPath`, keyed by normalized data paths including
674
+ // the schema's own data path, see `showValidationErrors()`. Errors are
675
+ // shown on all components that display their data path, and the first one
676
+ // in document order is focused.
677
+ async _showValidationErrorsByFullDataPath(
678
+ errorsByFullDataPath,
679
+ focus,
680
+ first
681
+ ) {
579
682
  this.clearErrors()
580
683
  const unmatched = []
581
684
  const wasFirst = first
582
- for (const [dataPath, errs] of Object.entries(errors)) {
583
- // If the schema is a data-root, prefix its own dataPath to all errors,
584
- // since the data that it sends and validates will be unprefixed.
585
- const fullDataPath = this.hasOwnData
586
- ? appendDataPath(this.dataPath, dataPath)
587
- : dataPath
588
- // console.log(this, this.dataPath, this.hasOwnData, fullDataPath)
589
- // Convert from JavaScript property access notation, to our own form
590
- // of relative JSON pointers as data-paths:
591
- const dataPathParts = parseDataPath(fullDataPath)
592
- let found = false
593
- const components = this.getComponentsByDataPath(dataPathParts)
594
- for (const component of components) {
595
- if (component.showValidationErrors(errs, first && focus)) {
596
- found = true
597
- first = false
598
- break
599
- }
600
- }
601
- if (!found) {
602
- // Couldn't find a component in an active form for the given dataPath.
603
- // See if we can find a component serving a part of the dataPath,
604
- // and take it from there:
605
- const property = dataPathParts.pop()
606
- while (dataPathParts.length > 0) {
607
- const components = this.getComponentsByDataPath(dataPathParts)
608
- for (const component of components) {
609
- const navigated = await component.navigateToComponent?.(
610
- fullDataPath,
611
- subComponents => {
612
- let found = false
613
- for (const component of subComponents) {
614
- const matched = Object.fromEntries(
615
- Object.entries(errors).filter(
616
- ([dataPath]) =>
617
- normalizeDataPath(dataPath).startsWith(
618
- component.dataPath
619
- )
620
- )
621
- )
622
- if (
623
- Object.keys(matched).length > 0 &&
624
- component.showValidationErrors(matched, first && focus)
625
- ) {
626
- found = true
627
- first = false
628
- break
629
- }
630
- }
631
- return found
632
- }
633
- )
634
- if (navigated) {
635
- // Found a nested form to display at least parts fo the errors.
636
- // We can't show all errors at once, so we're done. Don't call
637
- // `notifyErrors()` yet, as we can only display it once
638
- // `showValidationErrors()` was called from `DitoForm.mounted()`
639
- return
685
+ for (const [dataPath, errs] of Object.entries(errorsByFullDataPath)) {
686
+ let nestedForm = null
687
+ const displayingComponents = []
688
+ const hasFoundComponents = await this.navigateToComponent(
689
+ dataPath,
690
+ foundComponents => {
691
+ for (const component of foundComponents) {
692
+ if (component.isForm) {
693
+ // A nested form was navigated to, display the errors that are
694
+ // part of its data there.
695
+ nestedForm = component
696
+ } else {
697
+ displayingComponents.push(component)
640
698
  }
641
699
  }
642
- // Still here, so keep removing the last part until we find a match.
643
- dataPathParts.pop()
700
+ return true
701
+ }
702
+ )
703
+ // Focus the first component in document order, unless a nested form
704
+ // displays the errors:
705
+ sortInDocumentOrder(displayingComponents).forEach(
706
+ (component, index) => {
707
+ component.showValidationErrors(
708
+ errs,
709
+ first && focus && !nestedForm && index === 0
710
+ )
644
711
  }
712
+ )
713
+ if (nestedForm) {
714
+ const nestedFormErrors = Object.fromEntries(
715
+ Object.entries(errorsByFullDataPath).filter(
716
+ ([errorDataPath]) => (
717
+ errorDataPath === nestedForm.dataPath ||
718
+ errorDataPath.startsWith(`${nestedForm.dataPath}/`)
719
+ )
720
+ )
721
+ )
722
+ // The nested form notifies the errors itself, as it can't display
723
+ // the remaining errors of this schema, so we're done here.
724
+ return nestedForm.showValidationErrors(
725
+ nestedFormErrors,
726
+ first && focus
727
+ )
728
+ }
729
+ if (!hasFoundComponents) {
645
730
  // When the error can't be matched, add it to a list of unmatched
646
731
  // errors with decent message, to report at the end.
647
- const field = labelize(property)
732
+ const field = labelize(parseDataPath(dataPath).pop())
648
733
  for (const err of errs) {
649
734
  const prefix = field
650
735
  ? `The field ${field}`
@@ -671,12 +756,13 @@ export default DitoComponent.component('DitoSchema', {
671
756
  resetData() {
672
757
  // We can't set `this.data = ...` because it's a property, but we can set
673
758
  // all known properties on it to the values returned by
674
- // `setDefaultValues()`, as they are all reactive already from the starts:
759
+ // `initializeData()`, as they are all reactive already from the starts:
675
760
  // eslint-disable-next-line vue/no-mutating-props
676
- Object.assign(this.data, setDefaultValues(this.dataSchema, {}, this))
761
+ Object.assign(this.data, initializeData(this.dataSchema, {}, this))
677
762
  this.clearErrors()
678
763
  },
679
764
 
765
+ // Merges the values of `data` into the schema's data, and returns it.
680
766
  setData(data) {
681
767
  for (const name in data) {
682
768
  if (name in this.data) {
@@ -689,6 +775,7 @@ export default DitoComponent.component('DitoSchema', {
689
775
  }
690
776
  }
691
777
  }
778
+ return this.data
692
779
  },
693
780
 
694
781
  filterData(data) {
@@ -733,26 +820,50 @@ export default DitoComponent.component('DitoSchema', {
733
820
  this.$schemaParentComponent()?._registerSchemaComponent(this, add)
734
821
  },
735
822
 
736
- _registerComponent(component, add) {
737
- this._registerEntry(this.componentsRegistry, component, add)
823
+ _registerComponent(component, add, componentPath) {
824
+ this._registerEntry(
825
+ this.componentsRegistry,
826
+ component,
827
+ add,
828
+ componentPath
829
+ )
738
830
  // Only register with the parent if schema shares data with it.
739
- this.parentSchemaComponent?._registerComponent(component, add)
831
+ this.parentSchemaComponent?._registerComponent(
832
+ component,
833
+ add,
834
+ componentPath
835
+ )
836
+ },
837
+
838
+ _registerUnnestedComponent(component, add, componentPath) {
839
+ // Unnested components are only registered with their own schema, as
840
+ // they share its data path, see `navigateToComponent()`.
841
+ this._registerEntry(
842
+ this.unnestedComponentsRegistry,
843
+ component,
844
+ add,
845
+ componentPath
846
+ )
740
847
  },
741
848
 
742
- _registerPane(pane, add) {
743
- this._registerEntry(this.panesRegistry, pane, add)
849
+ _registerPane(pane, add, componentPath) {
850
+ this._registerEntry(this.panesRegistry, pane, add, componentPath)
744
851
  },
745
852
 
746
- _registerPanel(panel, add) {
747
- this._registerEntry(this.panelsRegistry, panel, add)
853
+ _registerPanel(panel, add, componentPath) {
854
+ this._registerEntry(this.panelsRegistry, panel, add, componentPath)
748
855
  },
749
856
 
750
- _registerEntry(registry, entry, add) {
751
- const uid = entry.$uid
857
+ // Registries are keyed by component path. Component paths change when list
858
+ // items move, and the entries then register again under their new path,
859
+ // see `TypeMixin`, `DitoPane` and `DitoPanel`.
860
+ _registerEntry(registry, entry, add, componentPath = entry.componentPath) {
752
861
  if (add) {
753
- registry[uid] = entry
754
- } else {
755
- delete registry[uid]
862
+ registry[componentPath] = entry
863
+ } else if (registry[componentPath] === entry) {
864
+ // Unless another entry registered under the path already, e.g. when
865
+ // list items swap places.
866
+ delete registry[componentPath]
756
867
  }
757
868
  },
758
869
 
@@ -774,14 +885,36 @@ export default DitoComponent.component('DitoSchema', {
774
885
  },
775
886
 
776
887
  _getEntriesByDataPath(entriesByDataPath, dataPath) {
777
- return entriesByDataPath[normalizeDataPath(dataPath)] || []
888
+ return sortInDocumentOrder(
889
+ entriesByDataPath[normalizeDataPath(dataPath)] || []
890
+ )
778
891
  },
779
892
 
780
893
  _getEntriesByName(entriesByDataPath, name) {
781
- return entriesByDataPath[appendDataPath(this.dataPath, name)] || []
894
+ return sortInDocumentOrder(
895
+ entriesByDataPath[appendDataPath(this.dataPath, name)] || []
896
+ )
782
897
  }
783
898
  }
784
899
  })
900
+
901
+ // Sorts components that display the same data, e.g. in different tabs, in the
902
+ // document order of their elements, which is the order of their schemas.
903
+ function sortInDocumentOrder(components) {
904
+ return components.length > 1
905
+ ? [...components].sort(compareByDocumentPosition)
906
+ : components
907
+ }
908
+
909
+ function compareByDocumentPosition(component1, component2) {
910
+ const node1 = component1.$el
911
+ const node2 = component2.$el
912
+ if (!node1 || !node2 || node1 === node2) {
913
+ return 0
914
+ }
915
+ const position = node1.compareDocumentPosition(node2)
916
+ return position & Node.DOCUMENT_POSITION_FOLLOWING ? -1 : 1
917
+ }
785
918
  </script>
786
919
 
787
920
  <style lang="scss">
@@ -41,7 +41,7 @@ DitoSchema.dito-schema-inlined(
41
41
 
42
42
  <script>
43
43
  import DitoComponent from '../DitoComponent.js'
44
- import { isCompact } from '../utils/schema.js'
44
+ import { isCompact } from '../utils/schema/structure.js'
45
45
  // @vue/component
46
46
  export default DitoComponent.component('DitoSchemaInlined', {
47
47
  emits: ['delete'],
@@ -1,19 +1,16 @@
1
1
  <template lang="pug">
2
2
  .dito-tabs(role="tablist")
3
- template(
3
+ a.dito-tabs__link(
4
4
  v-for="(tabSchema, key) in tabs"
5
- )
6
- a.dito-tabs__link(
7
- v-if="shouldRenderSchema(tabSchema)"
8
- :id="getTabId(key)"
9
- :key="key"
10
- role="tab"
11
- :aria-selected="modelValue === key"
12
- :aria-controls="getPanelId(key)"
13
- :tabindex="modelValue === key ? 0 : -1"
14
- :class="{ 'dito-tabs__link--active': modelValue === key }"
15
- @click="$emit('update:modelValue', key)"
16
- ) {{ getLabel(tabSchema, key) }}
5
+ :id="getTabId(key)"
6
+ :key="key"
7
+ role="tab"
8
+ :aria-selected="modelValue === key"
9
+ :aria-controls="getTabPanelId(key)"
10
+ :tabindex="modelValue === key ? 0 : -1"
11
+ :class="{ 'dito-tabs__link--active': modelValue === key }"
12
+ @click="$emit('update:modelValue', key)"
13
+ ) {{ getLabel(tabSchema, key) }}
17
14
  </template>
18
15
 
19
16
  <script>
@@ -24,17 +21,18 @@ export default DitoComponent.component('DitoTabs', {
24
21
  emits: ['update:modelValue'],
25
22
  props: {
26
23
  tabs: { type: Object, default: null },
27
- modelValue: { type: String, default: null },
28
- dataPath: { type: String, default: '' }
24
+ modelValue: { type: String, default: null }
29
25
  },
30
26
 
31
27
  methods: {
28
+ // Tabs are identified through the component path of their schema, like in
29
+ // `DitoPane.tabId` and `tabPanelId`.
32
30
  getTabId(key) {
33
- return `${this.dataPath}-tab-${key}`
31
+ return `${this.schemaComponent.componentPath}-tab-${key}`
34
32
  },
35
33
 
36
- getPanelId(key) {
37
- return `${this.dataPath}-tabpanel-${key}`
34
+ getTabPanelId(key) {
35
+ return `${this.schemaComponent.componentPath}-tabpanel-${key}`
38
36
  }
39
37
  }
40
38
  })