diff --git a/packages/vue-vuetify/README.md b/packages/vue-vuetify/README.md
index e782a711d7..3dd4e0d24c 100644
--- a/packages/vue-vuetify/README.md
+++ b/packages/vue-vuetify/README.md
@@ -14,7 +14,7 @@ The renderers are in a preview state.
Install JSON Forms Core, Vue 3 and Vue 3 Vuetify Renderers.
```bash
-npm i --save @jsonforms/core @jsonforms/vue @jsonforms/vue-vuetify
+npm i --save @jsonforms/core @jsonforms/vue @jsonforms/vue-vuetify splitpanes
```
Also add the packages to the transpile dependencies in the `vite.config.js` file:
@@ -128,6 +128,44 @@ For more information on how JSON Forms can be configured, please see the [README
## Customization
+### Empty property names
+
+`allowEmptyPropertyNames` controls whether Additional Properties can add or rename
+properties to empty or whitespace-only names, and whether the Mixed renderer can
+rename tree properties to those names. It defaults to `false`: names for which
+`name.trim().length === 0` are rejected.
+
+Enable it globally through the JSON Forms `config` prop:
+
+```js
+const config = { allowEmptyPropertyNames: true };
+```
+
+Or set it for an object control through `uischema.options`:
+
+```json
+{
+ "type": "Control",
+ "scope": "#/properties/settings",
+ "options": {
+ "allowEmptyPropertyNames": true
+ }
+}
+```
+
+The UI-schema option takes precedence over global config, including an explicit
+`false` overriding global `true`.
+
+Accepted names are preserved exactly; trimming is only used to check whether a
+name is blank. Schema constraints such as `propertyNames.minLength` and duplicate
+name checks still apply when the option is enabled. Having a `propertyNames`
+schema does not automatically enable empty names.
+
+Existing empty or whitespace-only keys remain visible and editable, and can be
+deleted or renamed to a permitted name, subject to the usual permissions and
+schema restrictions. This option controls property **names**, not how empty
+property **values** are stored or cleared.
+
### Prepend and Append Slots
All control renderers now support `prepend` and `append` slots, allowing you to add custom content before or after input fields without creating entirely custom renderers.
diff --git a/packages/vue-vuetify/dev/components/ExampleSettings.vue b/packages/vue-vuetify/dev/components/ExampleSettings.vue
index 2c8df6fe2d..7e829997b0 100644
--- a/packages/vue-vuetify/dev/components/ExampleSettings.vue
+++ b/packages/vue-vuetify/dev/components/ExampleSettings.vue
@@ -464,6 +464,22 @@ const layouts = appstoreLayouts.map((value: AppstoreLayouts) => ({
+
+
+
+
+
+
+ Allow adding or renaming properties to empty or whitespace-only
+ names. Schema constraints still apply; UI-schema options override
+ this setting.
+
+
+
diff --git a/packages/vue-vuetify/dev/store/index.ts b/packages/vue-vuetify/dev/store/index.ts
index 1e8d436d23..4355b69e08 100644
--- a/packages/vue-vuetify/dev/store/index.ts
+++ b/packages/vue-vuetify/dev/store/index.ts
@@ -35,6 +35,7 @@ const appstore = reactive({
enableFilterErrorsBeforeTouch: false,
filterErrorKeywordsBeforeTouch: ['required'],
allowAdditionalPropertiesIfMissing: false,
+ allowEmptyPropertyNames: false,
},
locale: useLocalStorage('vuetify-example-locale', 'en'),
},
diff --git a/packages/vue-vuetify/dev/views/ExampleView.vue b/packages/vue-vuetify/dev/views/ExampleView.vue
index 653993197b..17269eadca 100644
--- a/packages/vue-vuetify/dev/views/ExampleView.vue
+++ b/packages/vue-vuetify/dev/views/ExampleView.vue
@@ -29,8 +29,7 @@ import examples from '../examples';
import { useAppStore } from '../store';
import { createAjv } from '../validate';
-import { Pane, Splitpanes } from 'splitpanes';
-import 'splitpanes/dist/splitpanes.css';
+import { VPane, VSplitpanes } from '../../src/components';
import { getCustomRenderersForExample } from '../renderers';
const { extendedVuetifyRenderers } = await import('../../src');
@@ -332,12 +331,11 @@ const handleAction = (action: Action) => {
@@ -509,28 +507,3 @@ const handleAction = (action: Action) => {
-
diff --git a/packages/vue-vuetify/package.json b/packages/vue-vuetify/package.json
index 083304fac3..472c0167bd 100644
--- a/packages/vue-vuetify/package.json
+++ b/packages/vue-vuetify/package.json
@@ -59,7 +59,7 @@
"doc": "typedoc --tsconfig tsconfig.typedoc.json --name 'JSON Forms Vue Vuetify Renderers' --out docs --entryPoints 'src/**/*.ts' 'lib/index.d.ts'",
"test": "vitest --watch=false",
"build-only": "cross-env NODE_OPTIONS=\"--max-old-space-size=4096\" vite build",
- "type-check": "vue-tsc -p tsconfig.lib.json"
+ "type-check": "vue-tsc -p tsconfig.lib.json && vue-tsc --noEmit -p tsconfig.vitest.json"
},
"peerDependencies": {
"@jsonforms/core": "3.9.0-alpha.1",
@@ -68,6 +68,7 @@
"dayjs": "^1.10.6",
"lodash": "^4.17.21",
"maska": "^2.1.11",
+ "splitpanes": "^3.1.5",
"vue": "^3.5.0",
"vuetify": "^3.11.0"
},
diff --git a/packages/vue-vuetify/src/complex/MixedRenderer.vue b/packages/vue-vuetify/src/complex/MixedRenderer.vue
index 5f572a7f17..f81347df80 100644
--- a/packages/vue-vuetify/src/complex/MixedRenderer.vue
+++ b/packages/vue-vuetify/src/complex/MixedRenderer.vue
@@ -1,13 +1,13 @@
-
+
diff --git a/packages/vue-vuetify/src/complex/components/AdditionalProperties.vue b/packages/vue-vuetify/src/complex/components/AdditionalProperties.vue
index d38df0b857..b52c5f358c 100644
--- a/packages/vue-vuetify/src/complex/components/AdditionalProperties.vue
+++ b/packages/vue-vuetify/src/complex/components/AdditionalProperties.vue
@@ -1,11 +1,25 @@
-
+
-
-
- {{ additionalPropertiesTitle }}
+
-
+ {{ additionalPropertiesTitle }}
+
+
+ >
+
{{ translations.addTooltip }}
-
+
-
-
+
-
+
+
-
+ />
+
+
+
+ open ? startRename(element.propertyName) : cancelRename()
+ "
+ >
+
+
+ {{
+ icons.current.value.itemEdit
+ }}
+
+
+
+
{{
@@ -83,9 +179,9 @@
{{ translations.removeTooltip }}
-
-
+
+
+
@@ -93,11 +189,12 @@
+
+
diff --git a/packages/vue-vuetify/src/complex/components/MixedLiteralDetail.vue b/packages/vue-vuetify/src/complex/components/MixedLiteralDetail.vue
new file mode 100644
index 0000000000..2f3aadb736
--- /dev/null
+++ b/packages/vue-vuetify/src/complex/components/MixedLiteralDetail.vue
@@ -0,0 +1,58 @@
+
+
+
+
diff --git a/packages/vue-vuetify/src/components/VPane.vue b/packages/vue-vuetify/src/components/VPane.vue
new file mode 100644
index 0000000000..780fae4d0e
--- /dev/null
+++ b/packages/vue-vuetify/src/components/VPane.vue
@@ -0,0 +1,18 @@
+
+
+
+
+
+
+
diff --git a/packages/vue-vuetify/src/components/VSplitpanes.sass b/packages/vue-vuetify/src/components/VSplitpanes.sass
new file mode 100644
index 0000000000..d82932599a
--- /dev/null
+++ b/packages/vue-vuetify/src/components/VSplitpanes.sass
@@ -0,0 +1,218 @@
+@use 'sass:math'
+@use 'vuetify/tools'
+
+$splitter-thickness: 1px
+$splitter-hover-thickness: 4px
+$splitter-hit-area: 8px
+$splitter-transition: background-color 0.15s ease, width 0.15s ease, height 0.15s ease
+$pane-transition: 0.25s cubic-bezier(0.4, 0, 0.2, 1)
+
+@mixin splitter-base
+ box-sizing: border-box
+ flex-shrink: 0
+ background-color: transparent
+ transition: none
+ position: relative
+
+@mixin splitter-line($width: $splitter-thickness, $height: 100%, $top: 0, $left: 0)
+ content: ''
+ position: absolute
+ top: $top
+ left: $left
+ width: $width
+ height: $height
+ background-color: rgba(var(--v-border-color), var(--v-border-opacity))
+ transition: $splitter-transition
+ z-index: 1
+
+@mixin splitter-hover-state($is-horizontal: false)
+ @if $is-horizontal
+ height: $splitter-hover-thickness
+ width: 100%
+ top: -1.5px
+ left: 0
+ @else
+ width: $splitter-hover-thickness
+ height: 100%
+ left: -1.5px
+ top: 0
+ background-color: rgb(var(--v-theme-primary))
+
+@mixin hit-area($is-horizontal: false)
+ content: ''
+ position: absolute
+ z-index: 0
+ background-color: transparent
+
+ @if $is-horizontal
+ top: -(math.div($splitter-hit-area, 2))
+ bottom: -(math.div($splitter-hit-area, 2))
+ left: 0
+ right: 0
+ cursor: row-resize
+ @else
+ left: -(math.div($splitter-hit-area, 2))
+ right: -(math.div($splitter-hit-area, 2))
+ top: 0
+ bottom: 0
+ cursor: col-resize
+
+@mixin pane-transition($property)
+ transition: none
+
+ .splitpanes:not(.splitpanes--dragging) &
+ transition: #{$property} $pane-transition
+
+@include tools.layer('components')
+ .splitpanes
+ display: flex
+ width: 100%
+ height: 100%
+
+ &--vertical
+ flex-direction: row
+
+ &--horizontal
+ flex-direction: column
+
+ &--dragging
+ -webkit-user-select: none
+ user-select: none
+ pointer-events: auto
+ transform: translateZ(0)
+ will-change: transform
+
+ *, *:before, *:after
+ transition: none !important
+
+ .splitpanes__splitter
+ pointer-events: auto
+
+ &[dir='rtl']
+ &.splitpanes--vertical
+ flex-direction: row-reverse
+
+ > .splitpanes__splitter
+ border-left: none
+ border-right: $splitter-thickness solid rgba(var(--v-border-color), var(--v-border-opacity))
+
+ .splitpanes__pane
+ width: 100%
+ height: 100%
+ overflow: hidden
+ transform: translateZ(0)
+ backface-visibility: hidden
+
+ .splitpanes--vertical &
+ @include pane-transition(width)
+
+ .splitpanes--horizontal &
+ @include pane-transition(height)
+
+ .splitpanes__splitter
+ .splitpanes--vertical > &
+ min-width: $splitter-thickness
+ cursor: col-resize
+
+ .splitpanes--horizontal > &
+ min-height: $splitter-thickness
+ cursor: row-resize
+
+ &:first-child
+ cursor: auto
+
+ &:focus-visible
+ outline: 2px solid rgb(var(--v-theme-primary))
+ outline-offset: -2px
+
+ .splitpanes.v-splitpanes
+ color: rgba(var(--v-theme-on-surface), var(--v-high-emphasis-opacity))
+
+ .splitpanes__pane
+ background-color: rgb(var(--v-theme-surface))
+
+ .splitpanes__splitter
+ @include splitter-base
+ transform: translateZ(0)
+ will-change: auto
+
+ &:before
+ @include splitter-line
+
+ &:hover:before
+ @include splitter-hover-state
+
+ .splitpanes--dragging &:before
+ @include splitter-hover-state
+ transition: none !important
+
+ .splitpanes &
+ z-index: 1
+
+ &.splitpanes--vertical > .splitpanes__splitter:after
+ @include hit-area
+
+ &.splitpanes--horizontal > .splitpanes__splitter
+ &:before
+ @include splitter-line(100%, $splitter-thickness)
+
+ &:hover:before
+ @include splitter-hover-state(true)
+
+ .splitpanes--dragging &:before
+ @include splitter-hover-state(true)
+ transition: none !important
+
+ &:after
+ @include hit-area(true)
+
+ .splitpanes.v-splitpanes.splitpanes--dragging
+ .splitpanes__pane
+ transition: none
+
+ .splitpanes__splitter:hover:before,
+ .splitpanes__splitter:focus:before,
+ .splitpanes__splitter:active:before
+ @include splitter-hover-state
+ transition: none
+
+ .splitpanes__pane .splitpanes__splitter:before
+ background-color: rgba(var(--v-border-color), var(--v-border-opacity))
+ width: $splitter-thickness
+ height: 100%
+ top: 0
+ left: 0
+ transition: none
+
+ .splitpanes__pane .splitpanes__splitter:hover:before,
+ .splitpanes__pane .splitpanes__splitter:focus:before,
+ .splitpanes__pane .splitpanes__splitter:active:before
+ @include splitter-hover-state
+ transition: none
+
+ &.splitpanes--horizontal .splitpanes__splitter:before,
+ .splitpanes__pane .splitpanes--horizontal .splitpanes__splitter:before
+ background-color: rgba(var(--v-border-color), var(--v-border-opacity))
+ width: 100%
+ height: $splitter-thickness
+ top: 0
+ left: 0
+ transition: none
+
+ &.splitpanes--horizontal .splitpanes__splitter:hover:before,
+ &.splitpanes--horizontal .splitpanes__splitter:focus:before,
+ &.splitpanes--horizontal .splitpanes__splitter:active:before,
+ .splitpanes__pane .splitpanes--horizontal .splitpanes__splitter:hover:before,
+ .splitpanes__pane .splitpanes--horizontal .splitpanes__splitter:focus:before,
+ .splitpanes__pane .splitpanes--horizontal .splitpanes__splitter:active:before
+ @include splitter-hover-state(true)
+ transition: none
+
+ @media (prefers-contrast: high)
+ .splitpanes.v-splitpanes .splitpanes__splitter
+ opacity: 1 !important
+ background-color: rgba(var(--v-border-color), 0.87) !important
+
+ @media (prefers-reduced-motion: reduce)
+ .splitpanes__pane
+ transition: none
diff --git a/packages/vue-vuetify/src/components/VSplitpanes.vue b/packages/vue-vuetify/src/components/VSplitpanes.vue
new file mode 100644
index 0000000000..79192cf2a8
--- /dev/null
+++ b/packages/vue-vuetify/src/components/VSplitpanes.vue
@@ -0,0 +1,27 @@
+
+
+
+
+
+
+
diff --git a/packages/vue-vuetify/src/components/index.ts b/packages/vue-vuetify/src/components/index.ts
new file mode 100644
index 0000000000..83c7dd2e8e
--- /dev/null
+++ b/packages/vue-vuetify/src/components/index.ts
@@ -0,0 +1,2 @@
+export { default as VPane } from './VPane.vue';
+export { default as VSplitpanes } from './VSplitpanes.vue';
diff --git a/packages/vue-vuetify/src/i18n/additionalPropertiesTranslations.ts b/packages/vue-vuetify/src/i18n/additionalPropertiesTranslations.ts
index 632415d9b7..6002efa424 100644
--- a/packages/vue-vuetify/src/i18n/additionalPropertiesTranslations.ts
+++ b/packages/vue-vuetify/src/i18n/additionalPropertiesTranslations.ts
@@ -10,6 +10,10 @@ export enum AdditionalPropertiesTranslationEnum {
addAriaLabel = 'addAriaLabel',
removeTooltip = 'removeTooltip',
removeAriaLabel = 'removeAriaLabel',
+ renameTooltip = 'renameTooltip',
+ renameAriaLabel = 'renameAriaLabel',
+ renameConfirm = 'renameConfirm',
+ cancel = 'cancel',
propertyNameLabel = 'propertyNameLabel',
propertyNameInvalid = 'propertyNameInvalid',
propertyAlreadyDefined = 'propertyAlreadyDefined',
@@ -39,6 +43,23 @@ export const additionalPropertiesDefaultTranslations: AdditionalPropertiesDefaul
key: AdditionalPropertiesTranslationEnum.removeAriaLabel,
default: () => 'Delete button',
},
+ {
+ key: AdditionalPropertiesTranslationEnum.renameTooltip,
+ default: () => 'Rename',
+ },
+ {
+ key: AdditionalPropertiesTranslationEnum.renameAriaLabel,
+ default: (input) =>
+ input ? `Rename property '${input}' button` : 'Rename property button',
+ },
+ {
+ key: AdditionalPropertiesTranslationEnum.renameConfirm,
+ default: () => 'Rename',
+ },
+ {
+ key: AdditionalPropertiesTranslationEnum.cancel,
+ default: () => 'Cancel',
+ },
{
key: AdditionalPropertiesTranslationEnum.propertyNameLabel,
default: () => 'Property Name',
diff --git a/packages/vue-vuetify/src/i18n/i18nUtil.ts b/packages/vue-vuetify/src/i18n/i18nUtil.ts
index 560c5c2255..ed7cb50e60 100644
--- a/packages/vue-vuetify/src/i18n/i18nUtil.ts
+++ b/packages/vue-vuetify/src/i18n/i18nUtil.ts
@@ -5,6 +5,22 @@ import {
type AdditionalPropertiesTranslations,
} from './additionalPropertiesTranslations';
import { addI18nKeyToPrefix, type Translator } from '@jsonforms/core';
+import {
+ mixedRendererDefaultTranslations,
+ MixedRendererTranslationEnum,
+} from './mixedRendererTranslations';
+
+export const getAdditionalPropertyTranslation = (
+ t: Translator,
+ defaultTranslations: AdditionalPropertiesDefaultTranslation[],
+ i18nKeyPrefix: string,
+ key: AdditionalPropertiesTranslationEnum,
+ variable?: string | null,
+): string => {
+ const translation = defaultTranslations.find((entry) => entry.key === key);
+ const defaultMessage = translation?.default(variable) ?? key;
+ return t(addI18nKeyToPrefix(i18nKeyPrefix, key), defaultMessage, variable);
+};
export const getAdditionalPropertiesTranslations = (
t: Translator,
@@ -41,3 +57,54 @@ export const getAdditionalPropertiesTranslations = (
});
return translations;
};
+
+export interface MixedRendererTranslations {
+ unsupportedPropertyName: string;
+ searchLabel: string;
+ showPrimitives: string;
+ hidePrimitives: string;
+ renameTooltip: string;
+ renameAriaLabel: (propertyName: string) => string;
+ deleteTooltip: string;
+ deleteAriaLabel: (propertyName: string) => string;
+ viewTooltip: (label: string) => string;
+ viewAriaLabel: (label: string) => string;
+ itemLabel: (index: number) => string;
+}
+
+export const getMixedRendererTranslations = (
+ t: Translator,
+ i18nKeyPrefix: string,
+): MixedRendererTranslations => {
+ const translate = (
+ key: MixedRendererTranslationEnum,
+ variable?: string | number | null,
+ ): string => {
+ const translation = mixedRendererDefaultTranslations.find(
+ (entry) => entry.key === key,
+ );
+ const defaultMessage = translation?.default(variable) ?? key;
+ return t(addI18nKeyToPrefix(i18nKeyPrefix, key), defaultMessage, variable);
+ };
+
+ return {
+ unsupportedPropertyName: translate(
+ MixedRendererTranslationEnum.unsupportedPropertyName,
+ ),
+ searchLabel: translate(MixedRendererTranslationEnum.searchLabel),
+ showPrimitives: translate(MixedRendererTranslationEnum.showPrimitives),
+ hidePrimitives: translate(MixedRendererTranslationEnum.hidePrimitives),
+ renameTooltip: translate(MixedRendererTranslationEnum.renameTooltip),
+ renameAriaLabel: (propertyName) =>
+ translate(MixedRendererTranslationEnum.renameAriaLabel, propertyName),
+ deleteTooltip: translate(MixedRendererTranslationEnum.deleteTooltip),
+ deleteAriaLabel: (propertyName) =>
+ translate(MixedRendererTranslationEnum.deleteAriaLabel, propertyName),
+ viewTooltip: (label) =>
+ translate(MixedRendererTranslationEnum.viewTooltip, label),
+ viewAriaLabel: (label) =>
+ translate(MixedRendererTranslationEnum.viewAriaLabel, label),
+ itemLabel: (index) =>
+ translate(MixedRendererTranslationEnum.itemLabel, index),
+ };
+};
diff --git a/packages/vue-vuetify/src/i18n/index.ts b/packages/vue-vuetify/src/i18n/index.ts
index 42bda164bc..78b0632c34 100644
--- a/packages/vue-vuetify/src/i18n/index.ts
+++ b/packages/vue-vuetify/src/i18n/index.ts
@@ -1,2 +1,3 @@
export * from './additionalPropertiesTranslations';
export * from './i18nUtil';
+export * from './mixedRendererTranslations';
diff --git a/packages/vue-vuetify/src/i18n/mixedRendererTranslations.ts b/packages/vue-vuetify/src/i18n/mixedRendererTranslations.ts
new file mode 100644
index 0000000000..605ad47f17
--- /dev/null
+++ b/packages/vue-vuetify/src/i18n/mixedRendererTranslations.ts
@@ -0,0 +1,68 @@
+export interface MixedRendererDefaultTranslation {
+ key: MixedRendererTranslationEnum;
+ default: (variable?: string | number | null) => string;
+}
+
+export enum MixedRendererTranslationEnum {
+ unsupportedPropertyName = 'unsupportedPropertyName',
+ searchLabel = 'searchLabel',
+ showPrimitives = 'showPrimitives',
+ hidePrimitives = 'hidePrimitives',
+ renameTooltip = 'renameTooltip',
+ renameAriaLabel = 'renameAriaLabel',
+ deleteTooltip = 'deleteTooltip',
+ deleteAriaLabel = 'deleteAriaLabel',
+ viewTooltip = 'viewTooltip',
+ viewAriaLabel = 'viewAriaLabel',
+ itemLabel = 'itemLabel',
+}
+
+export const mixedRendererDefaultTranslations: MixedRendererDefaultTranslation[] =
+ [
+ {
+ key: MixedRendererTranslationEnum.unsupportedPropertyName,
+ default: () => 'This property name does not support editing.',
+ },
+ {
+ key: MixedRendererTranslationEnum.searchLabel,
+ default: () => 'Search',
+ },
+ {
+ key: MixedRendererTranslationEnum.showPrimitives,
+ default: () => 'Show primitives',
+ },
+ {
+ key: MixedRendererTranslationEnum.hidePrimitives,
+ default: () => 'Hide primitives',
+ },
+ {
+ key: MixedRendererTranslationEnum.renameTooltip,
+ default: () => 'Rename',
+ },
+ {
+ key: MixedRendererTranslationEnum.renameAriaLabel,
+ default: (input) =>
+ input ? `Rename property '${input}' button` : 'Rename property button',
+ },
+ {
+ key: MixedRendererTranslationEnum.deleteTooltip,
+ default: () => 'Delete',
+ },
+ {
+ key: MixedRendererTranslationEnum.deleteAriaLabel,
+ default: (input) =>
+ input ? `Delete property '${input}' button` : 'Delete property button',
+ },
+ {
+ key: MixedRendererTranslationEnum.viewTooltip,
+ default: (input) => (input ? `View ${input}` : 'View'),
+ },
+ {
+ key: MixedRendererTranslationEnum.viewAriaLabel,
+ default: (input) => (input ? `View ${input} button` : 'View button'),
+ },
+ {
+ key: MixedRendererTranslationEnum.itemLabel,
+ default: (input) => `Item ${input ?? ''}`.trim(),
+ },
+ ];
diff --git a/packages/vue-vuetify/src/icons/fa.ts b/packages/vue-vuetify/src/icons/fa.ts
index 4b914a7287..a9470c2297 100644
--- a/packages/vue-vuetify/src/icons/fa.ts
+++ b/packages/vue-vuetify/src/icons/fa.ts
@@ -1,14 +1,29 @@
import type { IconAliases } from './icons';
-const aliases: IconAliases = {
+const aliases: Required = {
itemAdd: 'fas fa-plus',
+ itemCancel: 'fas fa-xmark',
+ itemConfirm: 'fas fa-check',
+ itemEdit: 'far fa-pen-to-square',
itemMoveUp: 'fas fa-arrow-up',
itemMoveDown: 'fas fa-arrow-down',
itemDelete: 'fas fa-trash',
+ search: 'fas fa-magnifying-glass',
+ treeExpand: 'fas fa-chevron-right',
+ treeCollapse: 'fas fa-chevron-down',
+ visibilityOff: 'far fa-eye-slash',
+ visibilityOn: 'far fa-eye',
calendarClock: 'fas fa-calendar',
clock: 'far fa-clock',
passwordHide: 'far fa-eye-slash',
passwordShow: 'far fa-eye',
+ typeArray: 'fas fa-list',
+ typeBoolean: 'fas fa-toggle-on',
+ typeNull: 'fas fa-ban',
+ typeNumber: 'fas fa-hashtag',
+ typeObject: 'far fa-folder',
+ typeString: 'fas fa-font',
+ typeUnknown: 'fas fa-circle',
validationError: 'fas fa-circle-exclamation',
};
diff --git a/packages/vue-vuetify/src/icons/icons.ts b/packages/vue-vuetify/src/icons/icons.ts
index 12985eef09..dd05a3eb69 100644
--- a/packages/vue-vuetify/src/icons/icons.ts
+++ b/packages/vue-vuetify/src/icons/icons.ts
@@ -10,12 +10,27 @@ export type IconValue =
export interface IconAliases {
itemAdd: IconValue;
+ itemCancel?: IconValue;
+ itemConfirm?: IconValue;
+ itemEdit?: IconValue;
itemMoveUp: IconValue;
itemMoveDown: IconValue;
itemDelete: IconValue;
+ search?: IconValue;
+ treeExpand?: IconValue;
+ treeCollapse?: IconValue;
+ visibilityOff?: IconValue;
+ visibilityOn?: IconValue;
calendarClock: IconValue;
clock: IconValue;
passwordHide: IconValue;
passwordShow: IconValue;
+ typeArray?: IconValue;
+ typeBoolean?: IconValue;
+ typeNull?: IconValue;
+ typeNumber?: IconValue;
+ typeObject?: IconValue;
+ typeString?: IconValue;
+ typeUnknown?: IconValue;
validationError: IconValue;
}
diff --git a/packages/vue-vuetify/src/icons/mdi.ts b/packages/vue-vuetify/src/icons/mdi.ts
index d63940b2b4..4de6565a1c 100644
--- a/packages/vue-vuetify/src/icons/mdi.ts
+++ b/packages/vue-vuetify/src/icons/mdi.ts
@@ -1,14 +1,29 @@
import type { IconAliases } from './icons';
-const aliases: IconAliases = {
+const aliases: Required = {
itemAdd: 'mdi-plus',
+ itemCancel: 'mdi-close',
+ itemConfirm: 'mdi-check',
+ itemEdit: 'mdi-pencil-outline',
itemMoveUp: 'mdi-arrow-up',
itemMoveDown: 'mdi-arrow-down',
itemDelete: 'mdi-delete',
+ search: 'mdi-magnify',
+ treeExpand: 'mdi-chevron-right',
+ treeCollapse: 'mdi-chevron-down',
+ visibilityOff: 'mdi-eye-off',
+ visibilityOn: 'mdi-eye',
calendarClock: 'mdi-calendar-clock',
clock: 'mdi-clock-outline',
passwordHide: 'mdi-eye-off',
passwordShow: 'mdi-eye',
+ typeArray: 'mdi-format-list-bulleted',
+ typeBoolean: 'mdi-toggle-switch-outline',
+ typeNull: 'mdi-null',
+ typeNumber: 'mdi-numeric',
+ typeObject: 'mdi-folder-outline',
+ typeString: 'mdi-format-text',
+ typeUnknown: 'mdi-circle-small',
validationError: 'mdi-alert-circle-outline',
};
diff --git a/packages/vue-vuetify/src/util/additionalPropertySchema.ts b/packages/vue-vuetify/src/util/additionalPropertySchema.ts
new file mode 100644
index 0000000000..ebb2d513a8
--- /dev/null
+++ b/packages/vue-vuetify/src/util/additionalPropertySchema.ts
@@ -0,0 +1,131 @@
+import { Resolve, type JsonSchema } from '@jsonforms/core';
+import isEqual from 'lodash/isEqual';
+
+const patternCache = new WeakMap<
+ object,
+ { signatures: string[]; patterns: RegExp[] }
+>();
+const lowerBounds = new Set([
+ 'minimum',
+ 'exclusiveMinimum',
+ 'minLength',
+ 'minItems',
+ 'minProperties',
+]);
+const upperBounds = new Set([
+ 'maximum',
+ 'exclusiveMaximum',
+ 'maxLength',
+ 'maxItems',
+ 'maxProperties',
+]);
+const scalarKeywords = new Set([
+ 'type',
+ 'minimum',
+ 'maximum',
+ 'exclusiveMinimum',
+ 'exclusiveMaximum',
+ 'minLength',
+ 'maxLength',
+ 'multipleOf',
+ 'pattern',
+ 'format',
+ 'enum',
+ 'const',
+ 'title',
+ 'description',
+ 'default',
+ 'readOnly',
+ 'writeOnly',
+ 'i18n',
+]);
+
+/** Select the value schema for a dynamic key. Never changes the validation schema. */
+export function additionalPropertySchema(
+ name: string,
+ parent: JsonSchema,
+ root: JsonSchema,
+): JsonSchema {
+ const entries = parent.patternProperties ?? {};
+ const signatures = Object.keys(entries).sort();
+ let cached = patternCache.get(entries);
+ if (!cached || !isEqual(cached.signatures, signatures)) {
+ cached = {
+ signatures,
+ patterns: signatures.map((pattern) => new RegExp(pattern)),
+ };
+ patternCache.set(entries, cached);
+ }
+ const matches = cached.patterns.flatMap((pattern, index) =>
+ pattern.test(name) ? [entries[signatures[index]]] : [],
+ );
+ const normalize = (schema: JsonSchema | boolean): JsonSchema =>
+ schema === true ? {} : schema === false ? { not: {} } : schema;
+ const resolve = (
+ schema: JsonSchema,
+ seen = new Set(),
+ ): JsonSchema => {
+ if (!schema.$ref || seen.has(schema) || Object.keys(schema).length !== 1)
+ return schema;
+ const target = Resolve.schema(root, schema.$ref, root);
+ return target ? resolve(target, new Set(seen).add(schema)) : schema;
+ };
+ if (!matches.length) {
+ const fallback = parent.additionalProperties;
+ return resolve(
+ normalize(
+ fallback === undefined || fallback === true
+ ? { additionalProperties: true }
+ : fallback,
+ ),
+ );
+ }
+ const schemas = matches.map((schema) => resolve(normalize(schema)));
+ if (schemas.length === 1) return schemas[0];
+
+ // Structural schemas, unknown keywords, or conflicting assertions stay conjunctive.
+ const conjunction = { allOf: schemas } as JsonSchema;
+ const merged: Record = {};
+ for (const schema of schemas) {
+ for (const [key, value] of Object.entries(schema)) {
+ if (!scalarKeywords.has(key)) return conjunction;
+ // Draft-04 boolean exclusivity is coupled to that branch's bound.
+ if (
+ (key === 'exclusiveMinimum' || key === 'exclusiveMaximum') &&
+ typeof value === 'boolean'
+ )
+ return conjunction;
+ if (!(key in merged) || isEqual(merged[key], value)) {
+ merged[key] = value;
+ } else if (
+ typeof value === 'number' &&
+ typeof merged[key] === 'number' &&
+ (lowerBounds.has(key) || upperBounds.has(key))
+ ) {
+ merged[key] = lowerBounds.has(key)
+ ? Math.max(merged[key], value)
+ : Math.min(merged[key], value);
+ } else if (
+ key === 'type' &&
+ [merged[key], value].every(
+ (type) => type === 'integer' || type === 'number',
+ )
+ ) {
+ merged[key] = 'integer';
+ } else if (key === 'readOnly' || key === 'writeOnly') {
+ merged[key] = merged[key] || value;
+ } else {
+ return conjunction;
+ }
+ }
+ }
+ if (
+ merged.type !== undefined &&
+ !['string', 'number', 'integer', 'boolean', 'null'].includes(
+ merged.type as string,
+ )
+ ) {
+ return conjunction;
+ }
+ return merged as JsonSchema;
+}
diff --git a/packages/vue-vuetify/src/util/composition.ts b/packages/vue-vuetify/src/util/composition.ts
index 95ca301521..a9973e879f 100644
--- a/packages/vue-vuetify/src/util/composition.ts
+++ b/packages/vue-vuetify/src/util/composition.ts
@@ -508,7 +508,7 @@ export const useNested = (element: false | 'array' | 'object'): NestedInfo => {
};
export const useIcons = () => {
- const iconSet = computed(() => {
+ const iconSet = computed>(() => {
const icons = inject(IconSymbol);
if (!icons) throw new Error('Missing Vuetify Icons provide!');
diff --git a/packages/vue-vuetify/src/util/dynamicProperties.ts b/packages/vue-vuetify/src/util/dynamicProperties.ts
new file mode 100644
index 0000000000..445949fda1
--- /dev/null
+++ b/packages/vue-vuetify/src/util/dynamicProperties.ts
@@ -0,0 +1,221 @@
+import { additionalPropertySchema } from './additionalPropertySchema';
+import { Resolve, type JsonSchema, type JsonSchema7 } from '@jsonforms/core';
+import type Ajv from 'ajv';
+import type { ErrorObject } from 'ajv';
+
+export type DynamicPropertyNameValidationError =
+ | { reason: 'alreadyDefined' }
+ | { reason: 'invalid' }
+ | { reason: 'schema'; errors: ErrorObject[] };
+
+export interface DynamicPropertyNameValidationOptions {
+ propertyName: string;
+ /** Enable only when the caller updates literal keys at the parent path. */
+ allowDots?: boolean;
+ allowEmptyPropertyNames?: boolean;
+ currentPropertyName?: string;
+ data: unknown;
+ /** Names owned by the object schema, even when absent from data. */
+ reservedPropertyNames?: readonly string[];
+ propertyNameSchema?: JsonSchema7;
+ ajv?: Ajv;
+}
+
+export const composePropertyPath = (
+ parentPath: string,
+ propertyName: string,
+): string => (parentPath ? `${parentPath}.${propertyName}` : propertyName);
+
+export const getPathAncestorPaths = (
+ rootPath: string,
+ targetPath: string,
+): string[] => {
+ const relativePath =
+ rootPath && targetPath.startsWith(`${rootPath}.`)
+ ? targetPath.slice(rootPath.length + 1)
+ : targetPath === rootPath
+ ? ''
+ : targetPath;
+ const segments = relativePath.split('.').filter(Boolean);
+ const ancestors = [rootPath];
+ let currentPath = rootPath;
+
+ segments.slice(0, -1).forEach((segment) => {
+ currentPath = composePropertyPath(currentPath, segment);
+ ancestors.push(currentPath);
+ });
+
+ return ancestors;
+};
+
+export const getAdditionalPropertyNames = (
+ data: Record | undefined,
+ reservedPropertyNames: string[],
+): string[] =>
+ Object.keys(data ?? {}).filter(
+ (propertyName) => !reservedPropertyNames.includes(propertyName),
+ );
+
+export const haveAdditionalPropertyNamesChanged = (
+ newData: Record | undefined,
+ oldData: Record | undefined,
+ reservedPropertyNames: string[],
+): boolean => {
+ const newNames = getAdditionalPropertyNames(newData, reservedPropertyNames);
+ const oldNames = getAdditionalPropertyNames(oldData, reservedPropertyNames);
+ return (
+ newNames.length !== oldNames.length ||
+ newNames.some((propertyName, index) => propertyName !== oldNames[index])
+ );
+};
+
+export const findPropertySchema = (
+ parentSchema: JsonSchema,
+ propertyName: string,
+ rootSchema: JsonSchema,
+): JsonSchema | undefined => {
+ const declaredSchema = parentSchema.properties?.[propertyName];
+ if (declaredSchema) {
+ return typeof declaredSchema.$ref === 'string'
+ ? (Resolve.schema(rootSchema, declaredSchema.$ref, rootSchema) ??
+ declaredSchema)
+ : declaredSchema;
+ }
+
+ return additionalPropertySchema(propertyName, parentSchema, rootSchema);
+};
+
+/** Rename preserves property count, so only name ownership and editability matter. */
+export const canRenameDynamicProperty = ({
+ schema,
+ data,
+ propertyName,
+ enabled,
+ readonly,
+ restrict,
+}: {
+ schema: JsonSchema;
+ data: unknown;
+ propertyName: string;
+ enabled: boolean;
+ readonly: boolean;
+ restrict?: boolean;
+}): boolean =>
+ enabled &&
+ !readonly &&
+ typeof data === 'object' &&
+ data !== null &&
+ !Array.isArray(data) &&
+ Object.prototype.hasOwnProperty.call(data, propertyName) &&
+ !Object.prototype.hasOwnProperty.call(
+ schema.properties ?? {},
+ propertyName,
+ ) &&
+ !(restrict && schema.required?.includes(propertyName));
+
+export const getPropertyNameSchema = (
+ schema: JsonSchema,
+ rootSchema: JsonSchema,
+): JsonSchema7 => {
+ const constraints: JsonSchema7[] = [];
+ const propertyNames = (
+ schema as JsonSchema & { propertyNames?: JsonSchema7 | boolean }
+ ).propertyNames;
+
+ if (propertyNames === false) {
+ // Express a false schema without requiring boolean-schema type support.
+ constraints.push({ not: {} });
+ } else if (propertyNames && typeof propertyNames === 'object') {
+ const resolved =
+ typeof propertyNames.$ref === 'string'
+ ? (Resolve.schema(rootSchema, propertyNames.$ref, rootSchema) ??
+ propertyNames)
+ : propertyNames;
+ constraints.push(resolved as JsonSchema7);
+ }
+
+ if (schema.additionalProperties === false) {
+ const patterns = Object.keys(schema.patternProperties ?? {});
+ constraints.push(
+ patterns.length > 0
+ ? { anyOf: patterns.map((pattern) => ({ pattern })) }
+ : { not: {} },
+ );
+ }
+
+ return {
+ type: 'string',
+ ...(constraints.length > 0 ? { allOf: constraints } : {}),
+ };
+};
+
+export const validateDynamicPropertyName = ({
+ propertyName,
+ currentPropertyName,
+ data,
+ reservedPropertyNames = [],
+ allowDots = false,
+ allowEmptyPropertyNames = false,
+ propertyNameSchema,
+ ajv,
+}: DynamicPropertyNameValidationOptions): DynamicPropertyNameValidationError | null => {
+ if (!allowEmptyPropertyNames && propertyName.trim().length === 0) {
+ return { reason: 'invalid' };
+ }
+
+ if (reservedPropertyNames.includes(propertyName)) {
+ return { reason: 'alreadyDefined' };
+ }
+
+ if (
+ typeof data === 'object' &&
+ data !== null &&
+ Object.prototype.hasOwnProperty.call(data, propertyName) &&
+ propertyName !== currentPropertyName
+ ) {
+ return { reason: 'alreadyDefined' };
+ }
+
+ // JSON Forms data paths use dots as segment separators. Brackets are
+ // treated literally by core and therefore are valid in property names.
+ if (!allowDots && propertyName.includes('.')) {
+ return { reason: 'invalid' };
+ }
+
+ if (
+ propertyNameSchema &&
+ ajv &&
+ !ajv.validate(propertyNameSchema, propertyName)
+ ) {
+ return { reason: 'schema', errors: [...(ajv.errors ?? [])] };
+ }
+
+ return null;
+};
+
+export const getDynamicPropertyNameErrorMessage = (
+ error: DynamicPropertyNameValidationError | null,
+ messages: {
+ alreadyDefined: string;
+ invalid: string;
+ schema?: (error: ErrorObject) => string;
+ },
+): string | null => {
+ if (!error) {
+ return null;
+ }
+ if (error.reason === 'alreadyDefined') {
+ return messages.alreadyDefined;
+ }
+ if (error.reason === 'invalid') {
+ return messages.invalid;
+ }
+ const translatedErrors = error.errors
+ .map((schemaError) =>
+ messages.schema
+ ? messages.schema(schemaError)
+ : (schemaError.message ?? ''),
+ )
+ .filter(Boolean);
+ return translatedErrors.join(', ') || messages.invalid;
+};
diff --git a/packages/vue-vuetify/src/util/literalPropertySchema.ts b/packages/vue-vuetify/src/util/literalPropertySchema.ts
new file mode 100644
index 0000000000..fe0f78509c
--- /dev/null
+++ b/packages/vue-vuetify/src/util/literalPropertySchema.ts
@@ -0,0 +1,73 @@
+import type { JsonSchema, JsonSchema7 } from '@jsonforms/core';
+
+/** Keep local references rooted in the original document when editing a value
+ * in an isolated form. Only visit schema keywords: defaults and enums are data. */
+export const literalPropertySchema = (
+ schema: JsonSchema,
+ rootSchema: JsonSchema,
+): JsonSchema => {
+ const prefix = '#/definitions/__jsonforms_root';
+ const maps = new Set([
+ 'properties',
+ 'patternProperties',
+ 'definitions',
+ '$defs',
+ 'dependentSchemas',
+ ]);
+ const singles = new Set([
+ 'additionalProperties',
+ 'additionalItems',
+ 'contains',
+ 'propertyNames',
+ 'not',
+ 'if',
+ 'then',
+ 'else',
+ 'unevaluatedProperties',
+ 'unevaluatedItems',
+ ]);
+ const arrays = new Set(['allOf', 'anyOf', 'oneOf', 'prefixItems']);
+ const rewrite = (input: unknown): unknown => {
+ if (!input || typeof input !== 'object' || Array.isArray(input))
+ return input;
+ const source = input as Record;
+ return Object.fromEntries(
+ Object.entries(source).map(([key, value]) => {
+ if (
+ key === '$ref' &&
+ typeof value === 'string' &&
+ (value === '#' || value.startsWith('#/'))
+ ) {
+ return [key, `${prefix}${value.slice(1)}`];
+ }
+ if (maps.has(key) || key === 'dependencies') {
+ return [
+ key,
+ Object.fromEntries(
+ Object.entries(value as Record).map(
+ ([name, child]) => [name, rewrite(child)],
+ ),
+ ),
+ ];
+ }
+ if (singles.has(key)) return [key, rewrite(value)];
+ if (arrays.has(key) || key === 'items') {
+ return [
+ key,
+ Array.isArray(value) ? value.map(rewrite) : rewrite(value),
+ ];
+ }
+ return [key, value];
+ }),
+ );
+ };
+ const root = rewrite(rootSchema) as JsonSchema7;
+ // The bundled root is part of this document, not a new reference scope.
+ delete root.$id;
+ const value = rewrite(schema) as JsonSchema7;
+ delete value.$id;
+ return {
+ ...value,
+ definitions: { ...value.definitions, __jsonforms_root: root },
+ };
+};
diff --git a/packages/vue-vuetify/src/util/mixedLiteral.ts b/packages/vue-vuetify/src/util/mixedLiteral.ts
new file mode 100644
index 0000000000..42b75602b4
--- /dev/null
+++ b/packages/vue-vuetify/src/util/mixedLiteral.ts
@@ -0,0 +1,89 @@
+import { Resolve, type JsonSchema } from '@jsonforms/core';
+/** Tree-only segment encoding. These IDs must never be dispatched as core paths. */
+export const encodeMixedSegment = (key: string): string =>
+ key === '' || key.includes('.') || key.includes('\0')
+ ? '\0' +
+ key
+ .split('')
+ .map((c) => c.charCodeAt(0).toString(16).padStart(4, '0'))
+ .join('')
+ : key;
+export const decodeMixedSegment = (key: string): string =>
+ key.startsWith('\0')
+ ? (key.slice(1).match(/.{4}/g) ?? [])
+ .map((c) => String.fromCharCode(parseInt(c, 16)))
+ .join('')
+ : key;
+export function mixedValueAt(data: any, segments: string[]): any {
+ return segments.reduce(
+ (value, key) =>
+ value != null && Object.prototype.hasOwnProperty.call(value, key)
+ ? value[key]
+ : undefined,
+ data,
+ );
+}
+/** Copy only existing ancestors. An obsolete editor must not recreate removed nodes. */
+export function replaceMixedValue(
+ data: any,
+ segments: string[],
+ value: any,
+): any {
+ if (!segments.length) return value;
+ const [key, ...rest] = segments;
+ if (
+ data === null ||
+ typeof data !== 'object' ||
+ !Object.prototype.hasOwnProperty.call(data, key)
+ )
+ return data;
+ const next = replaceMixedValue(data[key], rest, value);
+ const copy = Array.isArray(data) ? [...data] : { ...data };
+ Object.defineProperty(copy, key, {
+ value: next,
+ enumerable: true,
+ configurable: true,
+ writable: true,
+ });
+ return copy;
+}
+
+/** Ordinary object Controls still cannot address declared dotted/empty scopes.
+ * Keep aggregate forms view-only; the tree edits each selected key in isolation. */
+export function mixedHasUnsafeDeclaredScopes(
+ schema: JsonSchema,
+ root: JsonSchema,
+ seen = new Set(),
+): boolean {
+ if (!schema || typeof schema !== 'object' || seen.has(schema)) return false;
+ seen.add(schema);
+ if (schema.$ref) {
+ const resolved = Resolve.schema(root, schema.$ref, root);
+ if (resolved && mixedHasUnsafeDeclaredScopes(resolved, root, seen))
+ return true;
+ }
+ for (const [key, child] of Object.entries(schema.properties ?? {})) {
+ if (
+ key === '' ||
+ key.includes('.') ||
+ key.includes('\0') ||
+ mixedHasUnsafeDeclaredScopes(child, root, seen)
+ )
+ return true;
+ }
+ const items = Array.isArray(schema.items)
+ ? schema.items
+ : schema.items
+ ? [schema.items]
+ : [];
+ return [
+ ...items,
+ ...(typeof schema.additionalProperties === 'object'
+ ? [schema.additionalProperties]
+ : []),
+ ...Object.values(schema.patternProperties ?? {}),
+ ...(schema.allOf ?? []),
+ ...(schema.anyOf ?? []),
+ ...(schema.oneOf ?? []),
+ ].some((child) => mixedHasUnsafeDeclaredScopes(child, root, seen));
+}
diff --git a/packages/vue-vuetify/src/util/mixedTree.ts b/packages/vue-vuetify/src/util/mixedTree.ts
new file mode 100644
index 0000000000..a94f4bb0f0
--- /dev/null
+++ b/packages/vue-vuetify/src/util/mixedTree.ts
@@ -0,0 +1,722 @@
+import {
+ encodeMixedSegment,
+ mixedHasUnsafeDeclaredScopes,
+} from './mixedLiteral';
+import {
+ Resolve,
+ createControlElement,
+ findUISchema,
+ type ControlElement,
+ type JsonFormsUISchemaRegistryEntry,
+ type JsonSchema,
+ type JsonSchema7,
+ type UISchemaElement,
+} from '@jsonforms/core';
+import cloneDeep from 'lodash/cloneDeep';
+import get from 'lodash/get';
+import isEqual from 'lodash/isEqual';
+import set from 'lodash/set';
+import {
+ canRenameDynamicProperty,
+ composePropertyPath,
+ findPropertySchema,
+ getPropertyNameSchema,
+} from './dynamicProperties';
+
+export type JsonDataType =
+ | 'array'
+ | 'boolean'
+ | 'integer'
+ | 'null'
+ | 'number'
+ | 'object'
+ | 'string';
+
+export const JSON_TYPES: JsonDataType[] = [
+ 'array',
+ 'boolean',
+ 'integer',
+ 'null',
+ 'number',
+ 'object',
+ 'string',
+];
+
+export interface TreeNodeControl {
+ schema: JsonSchema;
+ uischema: ControlElement;
+ path: string;
+ enabled: boolean;
+ readonly: boolean;
+}
+
+export interface MixedTreeNode {
+ nodeId: string;
+ title: string;
+ jsonType: JsonDataType;
+ label: string;
+ canRename: boolean;
+ canDelete: boolean;
+ control: TreeNodeControl;
+ /** Original allowed types for the selected node editor; control.schema is the tree view. */
+ editorSchema: JsonSchema;
+ children?: MixedTreeNode[];
+}
+
+export interface SchemaRenderInfo {
+ schema: JsonSchema;
+ resolvedSchema: JsonSchema;
+ uischema: UISchemaElement;
+ label: string;
+}
+
+const ROOT_TREE_NODE_ID = '$root';
+
+export const toTreeNodeId = (path: string): string =>
+ path ? `$path:${path}` : ROOT_TREE_NODE_ID;
+
+export const resolveSchema = (
+ schema: JsonSchema,
+ rootSchema: JsonSchema,
+): JsonSchema => {
+ if (typeof schema?.$ref === 'string') {
+ return Resolve.schema(rootSchema, schema.$ref, rootSchema) ?? schema;
+ }
+ return schema;
+};
+
+/** Project conjunctive structural constraints for tree actions. Core validates
+ * the original schema; introducing allOf here would change renderer selection.
+ * A later matching pattern must never relax an earlier bound. */
+const combineTreeSchemas = (
+ schemas: JsonSchema[],
+ rootSchema: JsonSchema,
+): JsonSchema7 => {
+ const parts = schemas.flatMap((schema) => {
+ const resolved = resolveSchema(schema, rootSchema) as JsonSchema7;
+ return [
+ resolved,
+ ...(resolved.allOf ?? []).map((part) =>
+ combineTreeSchemas([part], rootSchema),
+ ),
+ ];
+ });
+ if (parts.length === 1) return parts[0];
+ const result: JsonSchema7 = { ...parts[0] };
+ for (const part of parts.slice(1)) {
+ if (part.readOnly) result.readOnly = true;
+ result.required = [
+ ...new Set([...(result.required ?? []), ...(part.required ?? [])]),
+ ];
+ for (const key of ['minProperties', 'minItems'] as const) {
+ if (part[key] !== undefined)
+ result[key] = Math.max(result[key] ?? 0, part[key]!);
+ }
+ for (const key of ['maxProperties', 'maxItems'] as const) {
+ if (part[key] !== undefined)
+ result[key] = Math.min(result[key] ?? Infinity, part[key]!);
+ }
+ for (const key of ['properties', 'patternProperties'] as const) {
+ if (!part[key]) continue;
+ const entries: Record = { ...result[key] };
+ for (const [name, schema] of Object.entries(part[key]!)) {
+ entries[name] = Object.prototype.hasOwnProperty.call(entries, name)
+ ? combineTreeSchemas([entries[name], schema], rootSchema)
+ : schema;
+ }
+ result[key] = entries;
+ }
+ if (part.additionalProperties === false)
+ result.additionalProperties = false;
+ else if (result.additionalProperties === undefined)
+ result.additionalProperties = part.additionalProperties;
+ if (result.type === undefined) result.type = part.type;
+ if (part.items !== undefined && result.items === undefined)
+ result.items = part.items;
+ }
+ if (
+ parts.some(
+ (part) =>
+ part.propertyNames !== undefined || part.additionalProperties === false,
+ )
+ ) {
+ // Admission and propertyNames constraints are conjunctive too; resolve each
+ // name schema before embedding it so local $refs keep their root context.
+ result.propertyNames = {
+ allOf: parts.map((part) => getPropertyNameSchema(part, rootSchema)),
+ };
+ }
+ return result;
+};
+
+const findTreePropertySchema = (
+ parent: JsonSchema,
+ key: string,
+ root: JsonSchema,
+): JsonSchema | undefined => {
+ const schemas: JsonSchema[] = [];
+ const declared = parent.properties?.[key];
+ if (declared) schemas.push(declared);
+ for (const [pattern, schema] of Object.entries(
+ parent.patternProperties ?? {},
+ )) {
+ if (new RegExp(pattern).test(key)) schemas.push(schema);
+ }
+ return schemas.length
+ ? combineTreeSchemas(schemas, root)
+ : findPropertySchema(parent, key, root);
+};
+
+export const cleanSchema = (schema: JsonSchema): JsonSchema => {
+ const validKeywords: Record = {
+ array: ['items', 'minItems', 'maxItems', 'uniqueItems', 'contains'],
+ object: [
+ 'properties',
+ 'required',
+ 'additionalProperties',
+ 'minProperties',
+ 'maxProperties',
+ 'patternProperties',
+ 'dependencies',
+ 'propertyNames',
+ ],
+ string: ['minLength', 'maxLength', 'pattern', 'format'],
+ number: [
+ 'minimum',
+ 'maximum',
+ 'exclusiveMinimum',
+ 'exclusiveMaximum',
+ 'multipleOf',
+ ],
+ integer: [
+ 'minimum',
+ 'maximum',
+ 'exclusiveMinimum',
+ 'exclusiveMaximum',
+ 'multipleOf',
+ ],
+ boolean: [],
+ null: [],
+ };
+
+ const schemaType = schema.type as string;
+ for (const validType in validKeywords) {
+ if (validType !== schemaType) {
+ validKeywords[validType].forEach((key) => {
+ delete (schema as any)[key];
+ });
+ }
+ }
+
+ return schema;
+};
+
+export const getJsonDataType = (value: any): JsonDataType | null => {
+ if (typeof value === 'string') {
+ return 'string';
+ } else if (typeof value === 'number') {
+ return Number.isInteger(value) ? 'integer' : 'number';
+ } else if (typeof value === 'boolean') {
+ return 'boolean';
+ } else if (Array.isArray(value)) {
+ return 'array';
+ } else if (value === null) {
+ return 'null';
+ } else if (typeof value === 'object') {
+ return 'object';
+ }
+
+ return null;
+};
+
+export const getSchemaTypesAsArray = (schema: JsonSchema): string[] => {
+ if (typeof schema.type === 'string') {
+ return [schema.type];
+ }
+ if (Array.isArray(schema.type)) {
+ return schema.type;
+ }
+ if (Array.isArray(schema.enum)) {
+ const enumTypes = new Set(
+ schema.enum.map((value) => getJsonDataType(value)),
+ );
+ if (!enumTypes.has(null)) {
+ return Array.from(enumTypes).filter((type) => type !== null) as string[];
+ }
+ }
+ return [...JSON_TYPES];
+};
+
+export const schemaSupportsInputType = (
+ schemaType: JsonSchema['type'] | undefined,
+ dataType: JsonDataType | null,
+): boolean => {
+ if (!dataType || typeof schemaType !== 'string') {
+ return false;
+ }
+ return (
+ schemaType === dataType ||
+ (schemaType === 'number' && dataType === 'integer')
+ );
+};
+
+export const createMixedRenderInfos = (
+ parentSchema: JsonSchema,
+ schema: JsonSchema,
+ rootSchema: JsonSchema,
+ control: ControlElement,
+ path: string,
+ uischemas: JsonFormsUISchemaRegistryEntry[],
+): SchemaRenderInfo[] => {
+ const resolvedSourceSchema = resolveSchema(schema, rootSchema);
+ const resolvedSchemas =
+ typeof resolvedSourceSchema.type === 'string'
+ ? [resolvedSourceSchema]
+ : getSchemaTypesAsArray(resolvedSourceSchema).map((type) => ({
+ ...resolvedSourceSchema,
+ type,
+ default:
+ resolvedSourceSchema.default !== undefined &&
+ type === getJsonDataType(resolvedSourceSchema.default)
+ ? resolvedSourceSchema.default
+ : undefined,
+ }));
+
+ return resolvedSchemas.map((sourceSchema) => {
+ const resolvedSchema = cloneDeep(sourceSchema);
+ if (resolvedSchema.type === 'array') {
+ resolvedSchema.items = resolvedSchema.items ?? {};
+ resolvedSchema.items = cloneDeep(
+ resolveSchema(resolvedSchema.items as JsonSchema, rootSchema),
+ );
+
+ if ((resolvedSchema.items as any) === true) {
+ resolvedSchema.items = { type: [...JSON_TYPES] };
+ } else if (
+ typeof (resolvedSchema.items as JsonSchema7).type !== 'string' &&
+ !Array.isArray((resolvedSchema.items as JsonSchema7).type)
+ ) {
+ (resolvedSchema.items as JsonSchema7).type = [...JSON_TYPES];
+ }
+ }
+
+ let cleanedSchema = cleanSchema(cloneDeep(resolvedSchema));
+ const detailsForSchema = control.options
+ ? control.options[`${cleanedSchema.type}-detail`]
+ : undefined;
+ const schemaControl = detailsForSchema
+ ? {
+ ...control,
+ options: { ...control.options, detail: detailsForSchema },
+ }
+ : control;
+
+ if (
+ control.scope &&
+ (cleanedSchema.type === 'object' || cleanedSchema.type === 'array')
+ ) {
+ const segments = control.scope.split('/');
+ const startFromRoot = segments[0] === '#' || segments[0] === '';
+ const startIndex = startFromRoot ? 1 : 0;
+
+ if (segments.length > startIndex) {
+ const schemaPath = segments.slice(startIndex).join('.');
+ if (
+ schemaPath &&
+ isEqual(get(parentSchema, schemaPath), resolvedSourceSchema)
+ ) {
+ const newSchema = cloneDeep(parentSchema);
+ set(newSchema, schemaPath, cleanedSchema);
+ cleanedSchema = newSchema;
+ }
+ }
+ }
+
+ const uischema = findUISchema(
+ uischemas,
+ cleanedSchema,
+ control.scope,
+ path,
+ () => createControlElement(control.scope ?? '#'),
+ schemaControl,
+ rootSchema,
+ );
+
+ return {
+ schema: cleanedSchema,
+ resolvedSchema,
+ uischema,
+ label: `${resolvedSchema.type}`,
+ };
+ });
+};
+
+const getArrayItemSchema = (
+ parentSchema: JsonSchema,
+ index: number,
+ rootSchema: JsonSchema,
+): JsonSchema | undefined => {
+ if (!parentSchema.items) {
+ return undefined;
+ }
+
+ let itemSchema: JsonSchema | undefined;
+ if (Array.isArray(parentSchema.items)) {
+ if (index < parentSchema.items.length) {
+ itemSchema = parentSchema.items[index];
+ } else if (parentSchema.additionalItems) {
+ itemSchema =
+ typeof parentSchema.additionalItems === 'object'
+ ? parentSchema.additionalItems
+ : undefined;
+ }
+ } else {
+ itemSchema = parentSchema.items as JsonSchema;
+ }
+
+ return itemSchema ? resolveSchema(itemSchema, rootSchema) : undefined;
+};
+
+const prepareObjectSchema = (schema: JsonSchema): JsonSchema => {
+ const objectSchema = cleanSchema(cloneDeep({ ...schema, type: 'object' }));
+ objectSchema.additionalProperties =
+ objectSchema.additionalProperties !== false
+ ? (objectSchema.additionalProperties ?? true)
+ : false;
+ return objectSchema;
+};
+
+const prepareArraySchema = (
+ schema: JsonSchema,
+ rootSchema: JsonSchema,
+): JsonSchema => {
+ const arraySchema = cleanSchema(cloneDeep({ ...schema, type: 'array' }));
+ arraySchema.items = arraySchema.items ?? {};
+ arraySchema.items = cloneDeep(
+ resolveSchema(arraySchema.items as JsonSchema, rootSchema),
+ );
+
+ if ((arraySchema.items as any) === true) {
+ arraySchema.items = { type: [...JSON_TYPES] };
+ } else if (
+ typeof (arraySchema.items as JsonSchema7).type !== 'string' &&
+ !Array.isArray((arraySchema.items as JsonSchema7).type)
+ ) {
+ (arraySchema.items as JsonSchema7).type = [...JSON_TYPES];
+ }
+
+ return arraySchema;
+};
+
+const prepareChildSchema = (
+ childType: JsonDataType,
+ currentSchema: JsonSchema,
+ key: string,
+ index: number | null,
+ rootSchema: JsonSchema,
+ itemLabel?: string,
+): JsonSchema => {
+ let childSchema: JsonSchema | undefined;
+
+ if (index !== null) {
+ childSchema = getArrayItemSchema(currentSchema, index, rootSchema);
+ childSchema = childSchema
+ ? { ...childSchema, title: itemLabel }
+ : { type: [...JSON_TYPES], title: itemLabel };
+ } else {
+ childSchema = findTreePropertySchema(currentSchema, key, rootSchema);
+ childSchema = childSchema
+ ? { ...childSchema, title: key }
+ : { type: [...JSON_TYPES], title: key };
+ }
+
+ if (
+ childType !== 'object' &&
+ childType !== 'array' &&
+ (!childSchema.type || (childSchema.type as any) === true)
+ ) {
+ childSchema.type = [...JSON_TYPES];
+ }
+
+ return childSchema;
+};
+
+const getSchemaDefaultType = (schema: JsonSchema): JsonDataType => {
+ const schemaTypes = getSchemaTypesAsArray(schema);
+ const firstType =
+ schemaTypes.find((type) => type !== 'null') ?? schemaTypes[0];
+ return (firstType ?? 'object') as JsonDataType;
+};
+
+const createTreeNodeControl = (
+ schema: JsonSchema,
+ path: string,
+ enabled: boolean,
+ readonly: boolean,
+): TreeNodeControl => ({
+ schema,
+ uischema: createControlElement('#'),
+ path,
+ enabled,
+ readonly,
+});
+
+const withoutEmptyChildren = (node: MixedTreeNode): MixedTreeNode => {
+ const children = node.children?.map(withoutEmptyChildren) ?? [];
+ if (children.length === 0) {
+ const rest = { ...node };
+ delete rest.children;
+ return rest;
+ }
+ return { ...node, children };
+};
+
+const getDisplayTitle = (label: string, type: JsonDataType): string =>
+ label || (type === 'array' ? '[]' : '{}');
+
+export const buildTreeFromData = (
+ data: any,
+ schema: JsonSchema,
+ rootSchema: JsonSchema,
+ path: string,
+ label: string,
+ enabled: boolean,
+ readonly: boolean,
+ showPrimitives: boolean,
+ itemLabel: (index: number) => string,
+ restrict = false,
+): MixedTreeNode[] => {
+ const dataType = getJsonDataType(data);
+ if (dataType !== 'object' && dataType !== 'array') {
+ return [];
+ }
+
+ const nodes: MixedTreeNode[] = [];
+ const canDeleteChild = (
+ parent: any,
+ parentSchema: JsonSchema,
+ key: string,
+ nodeReadonly: boolean,
+ ): boolean => {
+ if (!enabled || nodeReadonly) return false;
+ if (!restrict) return true;
+ if (Array.isArray(parent)) {
+ return parent.length > (parentSchema.minItems ?? 0);
+ }
+ return (
+ !parentSchema.required?.includes(key) &&
+ Object.keys(parent).length > (parentSchema.minProperties ?? 0)
+ );
+ };
+
+ const traverse = (
+ value: any,
+ currentPath: string,
+ currentLabel: string,
+ currentSchema: JsonSchema,
+ children: MixedTreeNode[],
+ canRename = false,
+ canDelete = false,
+ inheritedReadonly = readonly,
+ ): void => {
+ const type = getJsonDataType(value);
+ currentSchema = combineTreeSchemas([currentSchema], rootSchema);
+ const nodeReadonly = inheritedReadonly || Boolean(currentSchema.readOnly);
+ canRename = canRename && !nodeReadonly;
+ canDelete = canDelete && !nodeReadonly;
+
+ if (type === 'object') {
+ const objectSchema = prepareObjectSchema(currentSchema);
+ const node: MixedTreeNode = {
+ nodeId: toTreeNodeId(currentPath),
+ title: getDisplayTitle(currentLabel, type),
+ jsonType: type,
+ label: currentLabel,
+ canRename,
+ canDelete,
+ editorSchema: currentSchema.type
+ ? currentSchema
+ : { ...currentSchema, type: [...JSON_TYPES] },
+ control: createTreeNodeControl(
+ objectSchema,
+ currentPath,
+ enabled,
+ nodeReadonly ||
+ mixedHasUnsafeDeclaredScopes(currentSchema, rootSchema),
+ ),
+ children: [],
+ };
+ children.push(node);
+
+ Object.keys(value).forEach((key) => {
+ const childValue = value[key];
+ const childPath = composePropertyPath(
+ currentPath,
+ encodeMixedSegment(key),
+ );
+ const rawChildType = getJsonDataType(childValue);
+ const initialChildSchema = findTreePropertySchema(
+ currentSchema,
+ key,
+ rootSchema,
+ ) ?? {
+ type: [...JSON_TYPES],
+ title: key,
+ };
+ const childType =
+ rawChildType ?? getSchemaDefaultType(initialChildSchema);
+ const childSchema = prepareChildSchema(
+ childType,
+ currentSchema,
+ key,
+ null,
+ rootSchema,
+ );
+ const childReadonly =
+ nodeReadonly ||
+ Boolean(combineTreeSchemas([childSchema], rootSchema).readOnly);
+ const childCanDelete = canDeleteChild(
+ value,
+ currentSchema,
+ key,
+ childReadonly,
+ );
+ const childCanRename = canRenameDynamicProperty({
+ schema: currentSchema,
+ data: value,
+ propertyName: key,
+ enabled,
+ readonly: childReadonly,
+ restrict,
+ });
+
+ if (childType === 'object' || childType === 'array') {
+ traverse(
+ childValue ?? (childType === 'array' ? [] : {}),
+ childPath,
+ key === '' ? '""' : key,
+ childSchema,
+ node.children!,
+ childCanRename,
+ childCanDelete,
+ nodeReadonly,
+ );
+ } else if (showPrimitives) {
+ node.children!.push({
+ nodeId: toTreeNodeId(childPath),
+ title: key === '' ? '""' : key,
+ jsonType: childType,
+ label: key === '' ? '""' : key,
+ editorSchema: childSchema,
+ canRename: childCanRename,
+ canDelete: childCanDelete,
+ control: createTreeNodeControl(
+ childSchema,
+ childPath,
+ enabled,
+ childReadonly,
+ ),
+ });
+ }
+ });
+ } else if (type === 'array') {
+ const arraySchema = prepareArraySchema(currentSchema, rootSchema);
+ const node: MixedTreeNode = {
+ nodeId: toTreeNodeId(currentPath),
+ title: getDisplayTitle(currentLabel, type),
+ jsonType: type,
+ label: currentLabel,
+ canRename,
+ canDelete,
+ editorSchema: currentSchema.type
+ ? currentSchema
+ : { ...currentSchema, type: [...JSON_TYPES] },
+ control: createTreeNodeControl(
+ arraySchema,
+ currentPath,
+ enabled,
+ nodeReadonly ||
+ mixedHasUnsafeDeclaredScopes(currentSchema, rootSchema),
+ ),
+ children: [],
+ };
+ children.push(node);
+
+ value.forEach((childValue: any, index: number) => {
+ const childType = getJsonDataType(childValue);
+ const childPath = composePropertyPath(currentPath, `${index}`);
+ const childLabel = itemLabel(index);
+ const childSchema = prepareChildSchema(
+ childType ?? 'object',
+ currentSchema,
+ '',
+ index,
+ rootSchema,
+ childLabel,
+ );
+ const childReadonly =
+ nodeReadonly ||
+ Boolean(combineTreeSchemas([childSchema], rootSchema).readOnly);
+ const childCanDelete = canDeleteChild(
+ value,
+ currentSchema,
+ `${index}`,
+ childReadonly,
+ );
+ const resolvedChildType =
+ childType ?? getSchemaDefaultType(childSchema);
+
+ if (resolvedChildType === 'object' || resolvedChildType === 'array') {
+ traverse(
+ childValue ?? (resolvedChildType === 'array' ? [] : {}),
+ childPath,
+ childLabel,
+ childSchema,
+ node.children!,
+ false,
+ childCanDelete,
+ nodeReadonly,
+ );
+ } else if (showPrimitives) {
+ node.children!.push({
+ nodeId: toTreeNodeId(childPath),
+ title: childLabel,
+ jsonType: resolvedChildType,
+ label: childLabel,
+ editorSchema: childSchema,
+ canRename: false,
+ canDelete: childCanDelete,
+ control: createTreeNodeControl(
+ childSchema,
+ childPath,
+ enabled,
+ childReadonly,
+ ),
+ });
+ }
+ });
+ }
+ };
+
+ traverse(data, path, label, resolveSchema(schema, rootSchema), nodes);
+ return nodes.map(withoutEmptyChildren);
+};
+
+export const flattenTree = (nodes: MixedTreeNode[]): MixedTreeNode[] =>
+ nodes.flatMap((node) => [node, ...flattenTree(node.children ?? [])]);
+
+export const findNodeById = (
+ nodes: MixedTreeNode[],
+ targetNodeId: string,
+): MixedTreeNode | undefined => {
+ for (const node of nodes) {
+ if (node.nodeId === targetNodeId) {
+ return node;
+ }
+ const child = findNodeById(node.children ?? [], targetNodeId);
+ if (child) {
+ return child;
+ }
+ }
+ return undefined;
+};
diff --git a/packages/vue-vuetify/tests/unit/additional/AdditionalPropertiesMixed.spec.ts b/packages/vue-vuetify/tests/unit/additional/AdditionalPropertiesMixed.spec.ts
new file mode 100644
index 0000000000..47f6ccb03a
--- /dev/null
+++ b/packages/vue-vuetify/tests/unit/additional/AdditionalPropertiesMixed.spec.ts
@@ -0,0 +1,150 @@
+import type { MixedTreeNode } from '../../../src/util/mixedTree';
+import { clearAllIds } from '@jsonforms/core';
+import { nextTick } from 'vue';
+import { flushPromises } from '@vue/test-utils';
+import { beforeEach, describe, expect, it } from 'vitest';
+import { extendedVuetifyRenderers } from '../../../src';
+import { mountJsonForms } from '../util';
+
+describe('AdditionalProperties mixed values', () => {
+ const schema = {
+ type: 'object' as const,
+ additionalProperties: {},
+ };
+ const uischema = { type: 'Control' as const, scope: '#' };
+
+ beforeEach(() => {
+ clearAllIds();
+ });
+
+ const mountDeletion = (value: unknown = { nested: true }, config = {}) => {
+ const wrapper = mountJsonForms(
+ { child: value },
+ { type: ['object', 'string'], minProperties: 1 },
+ extendedVuetifyRenderers,
+ uischema,
+ config,
+ );
+ const vm = wrapper.findComponent({ name: 'mixed-renderer' })
+ .vm as unknown as {
+ treeNodes: MixedTreeNode[];
+ pendingDeleteNode: MixedTreeNode | null;
+ deleteNode: (node: MixedTreeNode) => void;
+ confirmDelete: () => void;
+ };
+ return { wrapper, vm, node: vm.treeNodes[0].children![0] };
+ };
+
+ it.each([{ value: { nested: true } }, { value: [true] }])(
+ 'requires confirmation for nonempty %j and supports cancellation',
+ async ({ value }) => {
+ const { wrapper, vm, node } = mountDeletion(value);
+ vm.deleteNode(node);
+ await nextTick();
+ expect(vm.pendingDeleteNode?.nodeId).toBe(node.nodeId);
+ expect(wrapper.vm.event.data).toEqual({ child: value });
+ expect(document.body.textContent).toContain('all its nested content');
+ vm.pendingDeleteNode = null;
+ vm.confirmDelete();
+ await nextTick();
+ expect(wrapper.vm.event.data).toEqual({ child: value });
+ vm.deleteNode(node);
+ await flushPromises();
+ document.body
+ .querySelector('.mixed-confirm-delete')!
+ .click();
+ await nextTick();
+ expect(wrapper.vm.event.data).toEqual({});
+ await flushPromises();
+ wrapper.unmount();
+ },
+ );
+
+ it.each([{ value: {} }, { value: [] }])(
+ 'deletes empty %j immediately',
+ async ({ value }) => {
+ const { wrapper, vm, node } = mountDeletion(value);
+ vm.deleteNode(node);
+ await nextTick();
+ expect(vm.pendingDeleteNode).toBeNull();
+ expect(wrapper.vm.event.data).toEqual({});
+ await flushPromises();
+ wrapper.unmount();
+ },
+ );
+
+ it.each([
+ { restrict: true },
+ { readonly: true, separateReadonlyFromDisabled: true },
+ ])('rechecks permissions at confirmation with %j', async (config) => {
+ const { wrapper, vm, node } = mountDeletion();
+ vm.deleteNode(node);
+ await wrapper.setProps({ config });
+ vm.confirmDelete();
+ await nextTick();
+ expect(wrapper.vm.event.data).toEqual({ child: { nested: true } });
+ // A previously captured node cannot bypass the updated permissions either.
+ vm.deleteNode(node);
+ expect(vm.pendingDeleteNode).toBeNull();
+ await flushPromises();
+ wrapper.unmount();
+ });
+
+ it('cancels pending deletion when external data replaces the target', async () => {
+ const { wrapper, vm, node } = mountDeletion();
+ vm.deleteNode(node);
+ await wrapper.setProps({ data: { child: { replacement: true } } });
+ expect(vm.pendingDeleteNode).toBeNull();
+ vm.confirmDelete();
+ await nextTick();
+ expect(wrapper.vm.event.data).toEqual({ child: { replacement: true } });
+ await flushPromises();
+ wrapper.unmount();
+ });
+
+ it('updates the mixed renderer when an existing property changes type', async () => {
+ const wrapper = mountJsonForms(
+ { dynamic: 'text' },
+ schema,
+ extendedVuetifyRenderers,
+ uischema,
+ );
+
+ expect(wrapper.find('.mixed-primitive').exists()).toBe(true);
+
+ await wrapper.setProps({ data: { dynamic: { nested: true } } });
+ await nextTick();
+
+ expect(wrapper.find('.mixed-tree-container').exists()).toBe(true);
+ });
+
+ it.each(['object', 'array'] as const)(
+ 'opens the collapsible panel when selecting the %s type',
+ async (type) => {
+ const wrapper = mountJsonForms(
+ { dynamic: 'text' },
+ schema,
+ extendedVuetifyRenderers,
+ uischema,
+ );
+ const mixedRenderer = wrapper.findComponent({ name: 'mixed-renderer' });
+ const vm = mixedRenderer.vm as unknown as {
+ mixedRenderInfos: Array<{
+ index: number;
+ resolvedSchema: { type?: string };
+ }>;
+ handleSelectChange: (index: number) => void;
+ };
+ const selected = vm.mixedRenderInfos.find(
+ (info) => info.resolvedSchema.type === type,
+ );
+
+ expect(selected).toBeDefined();
+ vm.handleSelectChange(selected!.index);
+ await nextTick();
+
+ expect(wrapper.find('.v-expansion-panel--active').exists()).toBe(true);
+ expect(wrapper.find('.mixed-tree-container').exists()).toBe(true);
+ },
+ );
+});
diff --git a/packages/vue-vuetify/tests/unit/additional/MixedTreeReactivity.spec.ts b/packages/vue-vuetify/tests/unit/additional/MixedTreeReactivity.spec.ts
new file mode 100644
index 0000000000..afb1c38aa4
--- /dev/null
+++ b/packages/vue-vuetify/tests/unit/additional/MixedTreeReactivity.spec.ts
@@ -0,0 +1,136 @@
+import { afterAll, afterEach, beforeEach, expect, it, vi } from 'vitest';
+import { nextTick } from 'vue';
+import { flushPromises } from '@vue/test-utils';
+import type { JsonSchema7 } from '@jsonforms/core';
+import { extendedVuetifyRenderers } from '../../../src';
+import * as tree from '../../../src/util/mixedTree';
+import { mountJsonForms } from '../util';
+
+const wrappers: ReturnType[] = [];
+const build = vi.spyOn(tree, 'buildTreeFromData');
+afterAll(() => build.mockRestore());
+const scrollIntoView = Object.getOwnPropertyDescriptor(
+ HTMLElement.prototype,
+ 'scrollIntoView',
+);
+beforeEach(() => {
+ build.mockClear();
+ Object.defineProperty(HTMLElement.prototype, 'scrollIntoView', {
+ configurable: true,
+ value: vi.fn(),
+ });
+});
+afterEach(async () => {
+ await flushPromises();
+ wrappers.splice(0).forEach((wrapper) => wrapper.unmount());
+ if (scrollIntoView)
+ Object.defineProperty(
+ HTMLElement.prototype,
+ 'scrollIntoView',
+ scrollIntoView,
+ );
+ else Reflect.deleteProperty(HTMLElement.prototype, 'scrollIntoView');
+});
+const mountTree = (
+ data: unknown,
+ treeSchema: JsonSchema7 = { type: ['object', 'null'] },
+ options = {},
+) => {
+ const wrapper = mountJsonForms(
+ data,
+ {
+ type: 'object',
+ properties: { tree: treeSchema, outside: { type: 'number' } },
+ },
+ extendedVuetifyRenderers,
+ { type: 'Control', scope: '#/properties/tree', options },
+ );
+ wrappers.push(wrapper);
+ return wrapper;
+};
+const builds = () =>
+ build.mock.calls.filter((args) => args[3] === 'tree').length;
+
+it('does not traverse a large unchanged subtree for unrelated form updates', async () => {
+ const data = Object.fromEntries(
+ Array.from({ length: 200 }, (_, i) => [`key${i}`, { value: i }]),
+ );
+ // Exclude the detail form's hundreds of input widgets from this traversal test.
+ const wrapper = mountTree({ tree: data, outside: 0 }, undefined, {
+ detail: { type: 'VerticalLayout', elements: [] },
+ });
+ await flushPromises();
+ const before = builds();
+ for (let i = 1; i <= 10; i++) {
+ await wrapper.setProps({ data: { tree: data, outside: i } });
+ await nextTick();
+ }
+ const traversals = builds() - before;
+ console.info(
+ `Unrelated edits: 10; tree nodes: 401; full tree traversals: ${traversals}`,
+ );
+ expect(traversals).toBe(0);
+});
+
+it('refreshes types, structure and schema restrictions after external changes', async () => {
+ const wrapper = mountTree({ tree: { child: 1 }, outside: 0 });
+ const before = builds();
+ await wrapper.setProps({
+ data: { tree: { child: { nested: true } }, outside: 0 },
+ });
+ await flushPromises();
+ expect(builds()).toBeGreaterThan(before);
+ expect(
+ build.mock.results[build.mock.results.length - 1]?.value[0].children[0]
+ .jsonType,
+ ).toBe('object');
+ const schema: JsonSchema7 = {
+ type: 'object',
+ properties: { tree: { type: ['object', 'null'], readOnly: true } },
+ };
+ await wrapper.setProps({ schema });
+ await flushPromises();
+ const nodes = tree.flattenTree(
+ build.mock.results[build.mock.results.length - 1]?.value,
+ );
+ expect(nodes.every((node) => node.control.readonly && !node.canDelete)).toBe(
+ true,
+ );
+});
+
+it('refreshes permissions without data changes and keeps the selected detail current', async () => {
+ const wrapper = mountTree(
+ { tree: { child: 1 }, outside: 0 },
+ { type: ['object', 'null'], minProperties: 1 },
+ );
+ const vm = wrapper.findComponent({ name: 'mixed-renderer' })
+ .vm as unknown as {
+ toggleShowPrimitives(): void;
+ activatedTreeNodes: string[];
+ selectedNode: tree.MixedTreeNode;
+ };
+ vm.toggleShowPrimitives();
+ await nextTick();
+ vm.activatedTreeNodes = [tree.toTreeNodeId('tree.child')];
+ await nextTick();
+ expect(vm.selectedNode.canDelete).toBe(true);
+ await wrapper.setProps({ config: { restrict: true } });
+ await nextTick();
+ expect(vm.selectedNode.canDelete).toBe(false);
+ await wrapper.setProps({
+ config: { readonly: true, separateReadonlyFromDisabled: true },
+ });
+ await nextTick();
+ expect(vm.selectedNode.control.readonly).toBe(true);
+ await wrapper.setProps({ config: { readonly: false } });
+ await nextTick();
+ expect(vm.selectedNode.control.readonly).toBe(false);
+ const before = builds();
+ const input = wrapper
+ .findAll('input')
+ .find((input) => input.element.value === '1')!;
+ await input.setValue('2');
+ await vi.waitFor(() => expect(wrapper.vm.event.data.tree.child).toBe(2));
+ expect(builds()).toBeGreaterThan(before);
+ expect(vm.activatedTreeNodes).toEqual([tree.toTreeNodeId('tree.child')]);
+});
diff --git a/packages/vue-vuetify/tests/unit/additional/RenamePermissions.spec.ts b/packages/vue-vuetify/tests/unit/additional/RenamePermissions.spec.ts
new file mode 100644
index 0000000000..a3413df111
--- /dev/null
+++ b/packages/vue-vuetify/tests/unit/additional/RenamePermissions.spec.ts
@@ -0,0 +1,224 @@
+import { nextTick } from 'vue';
+import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
+import { flushPromises } from '@vue/test-utils';
+import type { JsonSchema } from '@jsonforms/core';
+import { extendedVuetifyRenderers } from '../../../src';
+import { flattenTree, type MixedTreeNode } from '../../../src/util/mixedTree';
+import { mountJsonForms } from '../util';
+
+// jsdom does not implement scrolling; Vuetify scrolls the newly selected node.
+const scrollIntoView = Object.getOwnPropertyDescriptor(
+ HTMLElement.prototype,
+ 'scrollIntoView',
+);
+beforeAll(() => {
+ Object.defineProperty(HTMLElement.prototype, 'scrollIntoView', {
+ configurable: true,
+ value: vi.fn(),
+ });
+});
+afterAll(() => {
+ if (scrollIntoView)
+ Object.defineProperty(
+ HTMLElement.prototype,
+ 'scrollIntoView',
+ scrollIntoView,
+ );
+ else Reflect.deleteProperty(HTMLElement.prototype, 'scrollIntoView');
+});
+
+type RenameVM = {
+ treeNodes: MixedTreeNode[];
+ renameValue: string;
+ renameError: string | null;
+ toggleShowPrimitives: () => void;
+ startRename: (node: MixedTreeNode | string) => void;
+ commitRename: (node: MixedTreeNode) => void;
+ renameProperty: (name: string) => void;
+ renamePropertyDisabled: (name: string) => boolean;
+ newPropertyName: string;
+ addProperty: () => void;
+};
+
+const mountRename = async (
+ tree: boolean,
+ schema: JsonSchema = {},
+ config = {},
+) => {
+ const wrapper = mountJsonForms(
+ { first: 1, dynamic: 2, last: 3 },
+ {
+ type: tree ? ['object', 'string'] : 'object',
+ additionalProperties: { type: 'number' },
+ ...schema,
+ },
+ extendedVuetifyRenderers,
+ { type: 'Control', scope: '#' },
+ config,
+ );
+ const vm = wrapper.findComponent({
+ name: tree ? 'mixed-renderer' : 'additional-properties',
+ }).vm as unknown as RenameVM;
+ if (tree) {
+ vm.toggleShowPrimitives();
+ await nextTick();
+ }
+ const node = tree
+ ? flattenTree(vm.treeNodes).find((node) => node.control.path === 'dynamic')!
+ : undefined;
+ return {
+ wrapper,
+ vm,
+ start: () => vm.startRename(node ?? 'dynamic'),
+ commit: () =>
+ tree ? vm.commitRename(node!) : vm.renameProperty('dynamic'),
+ };
+};
+
+describe.each([false, true])('rename permissions (tree=%s)', (tree) => {
+ it.each([
+ { readonly: true, separateReadonlyFromDisabled: true },
+ { readonly: true },
+ { restrict: true },
+ ])('rechecks permissions after editing begins: %j', async (config) => {
+ const { wrapper, vm, start, commit } = await mountRename(tree, {
+ required: ['dynamic'],
+ });
+ start();
+ vm.renameValue = 'renamed';
+ await wrapper.setProps({ config });
+ commit();
+ await nextTick();
+ expect(wrapper.vm.event.data).toEqual({ first: 1, dynamic: 2, last: 3 });
+ await flushPromises();
+ wrapper.unmount();
+ });
+
+ it('preserves value and order without enforcing property-count limits on rename', async () => {
+ const { wrapper, vm, start, commit } = await mountRename(
+ tree,
+ { minProperties: 3, maxProperties: 3 },
+ { restrict: true },
+ );
+ start();
+ vm.renameValue = 'renamed';
+ commit();
+ await nextTick();
+ expect(wrapper.vm.event.data).toEqual({ first: 1, renamed: 2, last: 3 });
+ expect(Object.keys(wrapper.vm.event.data)).toEqual([
+ 'first',
+ 'renamed',
+ 'last',
+ ]);
+ await flushPromises();
+ wrapper.unmount();
+ });
+
+ it('allows renaming a required dynamic name when restrict is off', async () => {
+ const { wrapper, vm, start, commit } = await mountRename(tree, {
+ required: ['dynamic'],
+ });
+ start();
+ vm.renameValue = 'renamed';
+ commit();
+ await nextTick();
+ expect(wrapper.vm.event.data).toEqual({ first: 1, renamed: 2, last: 3 });
+ await flushPromises();
+ wrapper.unmount();
+ });
+
+ it.each([false, true])(
+ 'reserves absent declared names with restrict=%s',
+ async (restrict) => {
+ const { wrapper, vm, start, commit } = await mountRename(
+ tree,
+ {
+ properties: { fixed: { type: 'number' } },
+ patternProperties: { '^fixed$': { type: 'number' } },
+ },
+ { restrict },
+ );
+ start();
+ vm.renameValue = 'fixed';
+ if (!tree) expect(vm.renamePropertyDisabled('dynamic')).toBe(true);
+ commit();
+ await nextTick();
+ expect(vm.renameError).toContain('already defined');
+ expect(wrapper.vm.event.data).toEqual({ first: 1, dynamic: 2, last: 3 });
+ // A rejected attempt must not prevent a subsequent valid rename.
+ vm.renameValue = 'available';
+ commit();
+ await nextTick();
+ expect(wrapper.vm.event.data).toEqual({
+ first: 1,
+ available: 2,
+ last: 3,
+ });
+ await flushPromises();
+ wrapper.unmount();
+ },
+ );
+
+ it('keeps shared name validation active', async () => {
+ const { wrapper, vm, start, commit } = await mountRename(tree, {
+ propertyNames: { minLength: 5 },
+ patternProperties: { '^dynamic': { type: 'number' } },
+ additionalProperties: false,
+ });
+ start();
+ vm.renameValue = 'wrong_name';
+ commit();
+ await nextTick();
+ expect(vm.renameError).toBeTruthy();
+ expect(wrapper.vm.event.data.dynamic).toBe(2);
+ await flushPromises();
+ wrapper.unmount();
+ });
+});
+
+it('uses the resolved tuple item schema for tree rename', async () => {
+ const wrapper = mountJsonForms(
+ [{ old: {} }, { old: {} }],
+ {
+ type: ['array', 'string'],
+ items: [
+ { type: 'object', propertyNames: { pattern: '^first' } },
+ { $ref: '#/definitions/second' },
+ ],
+ definitions: {
+ second: { type: 'object', propertyNames: { pattern: '^second' } },
+ },
+ } as JsonSchema,
+ extendedVuetifyRenderers,
+ { type: 'Control', scope: '#' },
+ );
+ const vm = wrapper.findComponent({ name: 'mixed-renderer' })
+ .vm as unknown as RenameVM;
+ const node = flattenTree(vm.treeNodes).find(
+ (node) => node.control.path === '1.old',
+ )!;
+ vm.startRename(node);
+ vm.renameValue = 'firstName';
+ vm.commitRename(node);
+ await nextTick();
+ expect(vm.renameError).toBeTruthy();
+ expect(wrapper.vm.event.data).toEqual([{ old: {} }, { old: {} }]);
+ vm.renameValue = 'secondName';
+ vm.commitRename(node);
+ await nextTick();
+ expect(wrapper.vm.event.data).toEqual([{ old: {} }, { secondName: {} }]);
+ await flushPromises();
+ wrapper.unmount();
+});
+
+it('rejects adding an absent declared key in the submission handler', async () => {
+ const { wrapper, vm } = await mountRename(false, {
+ properties: { fixed: { type: 'number' } },
+ });
+ vm.newPropertyName = 'fixed';
+ vm.addProperty();
+ await nextTick();
+ expect(wrapper.vm.event.data).toEqual({ first: 1, dynamic: 2, last: 3 });
+ await flushPromises();
+ wrapper.unmount();
+});
diff --git a/packages/vue-vuetify/tests/unit/additional/StructuralEditing.spec.ts b/packages/vue-vuetify/tests/unit/additional/StructuralEditing.spec.ts
new file mode 100644
index 0000000000..9ee929b9a8
--- /dev/null
+++ b/packages/vue-vuetify/tests/unit/additional/StructuralEditing.spec.ts
@@ -0,0 +1,1308 @@
+import type { ControlElement } from '@jsonforms/core';
+import type { JsonSchema7 as JsonSchema } from '@jsonforms/core';
+import { nextTick } from 'vue';
+import { flushPromises } from '@vue/test-utils';
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+import { VSelect } from 'vuetify/components';
+import { extendedVuetifyRenderers } from '../../../src';
+import {
+ buildTreeFromData,
+ flattenTree,
+ type MixedTreeNode,
+} from '../../../src/util/mixedTree';
+import { mountJsonForms } from '../util';
+
+type EditorVM = {
+ control: { path: string };
+ treeNodes: MixedTreeNode[];
+ deleteNode: (node: MixedTreeNode) => void;
+ confirmDelete: () => void;
+ selectedNode: MixedTreeNode;
+ treeSearch: string;
+ activatedTreeNodes: string[];
+ mixedRenderInfos: { index: number; resolvedSchema: JsonSchema }[];
+ selectedIndex: number;
+ handleSelectChange: (index: number | null) => void;
+ toggleShowPrimitives: () => void;
+ renameValue: string;
+ renamingNodeId: string | null;
+ renamingPropertyName: string | null;
+ startRename: (node: MixedTreeNode | string) => void;
+ commitRename: (node: MixedTreeNode) => void;
+ renameProperty: (name: string) => void;
+ newPropertyName: string;
+ addProperty: () => void;
+ removeProperty: (name: string) => void;
+ additionalPropertyItems: { propertyName: string; schema: JsonSchema }[];
+};
+const wrappers: ReturnType[] = [];
+const scrollIntoView = Object.getOwnPropertyDescriptor(
+ HTMLElement.prototype,
+ 'scrollIntoView',
+);
+const mountEditor = (
+ data: unknown,
+ schema: JsonSchema,
+ config = {},
+ uischema: ControlElement = { type: 'Control', scope: '#' },
+) => {
+ const wrapper = mountJsonForms(
+ data,
+ schema,
+ extendedVuetifyRenderers,
+ uischema,
+ config,
+ );
+ wrappers.push(wrapper);
+ return wrapper;
+};
+const vmOf = (wrapper: ReturnType, name: string) =>
+ wrapper.findComponent({ name }).vm as unknown as EditorVM;
+beforeEach(() => {
+ // Vuetify scrolls activated tree nodes; jsdom has no scrolling implementation.
+ Object.defineProperty(HTMLElement.prototype, 'scrollIntoView', {
+ configurable: true,
+ value: vi.fn(),
+ });
+});
+afterEach(async () => {
+ await flushPromises();
+ wrappers.splice(0).forEach((wrapper) => wrapper.unmount());
+ if (scrollIntoView)
+ Object.defineProperty(
+ HTMLElement.prototype,
+ 'scrollIntoView',
+ scrollIntoView,
+ );
+ else Reflect.deleteProperty(HTMLElement.prototype, 'scrollIntoView');
+});
+
+describe('AdditionalProperties structural mutations', () => {
+ it.each([
+ {
+ schema: { required: ['key'] },
+ config: { restrict: true },
+ action: 'delete',
+ },
+ {
+ schema: { minProperties: 1 },
+ config: { restrict: true },
+ action: 'delete',
+ },
+ { schema: { maxProperties: 1 }, config: { restrict: true }, action: 'add' },
+ { schema: {}, config: { readonly: true }, action: 'add' },
+ { schema: {}, config: { readonly: true }, action: 'delete' },
+ {
+ schema: {},
+ config: { readonly: true, separateReadonlyFromDisabled: true },
+ action: 'add',
+ },
+ {
+ schema: {},
+ config: { readonly: true, separateReadonlyFromDisabled: true },
+ action: 'delete',
+ },
+ ])(
+ 'guards the $action handler with $schema and $config',
+ async ({ schema, config, action }) => {
+ const wrapper = mountEditor(
+ { key: 1 },
+ { type: 'object', additionalProperties: { type: 'number' }, ...schema },
+ );
+ const vm = vmOf(wrapper, 'additional-properties');
+ vm.newPropertyName = 'newKey';
+ // Invoke the captured handler after permissions change, bypassing button state.
+ await wrapper.setProps({ config });
+ if (action === 'add') vm.addProperty();
+ else vm.removeProperty('key');
+ await nextTick();
+ expect(wrapper.vm.event.data).toEqual({ key: 1 });
+ expect(
+ vm.additionalPropertyItems.map((item) => item.propertyName),
+ ).toEqual(['key']);
+ },
+ );
+
+ it('disables Delete for a required key but permits deleting an optional key', async () => {
+ const wrapper = mountEditor(
+ { key: 1, optional: 2 },
+ {
+ type: 'object',
+ additionalProperties: { type: 'number' },
+ required: ['key'],
+ },
+ { restrict: true },
+ );
+ const rows = wrapper.findAll('.additional-property-row');
+ expect(
+ rows[0].findAll('button').slice(-1)[0].attributes('disabled'),
+ ).toBeDefined();
+ expect(
+ rows[1].findAll('button').slice(-1)[0].attributes('disabled'),
+ ).toBeUndefined();
+ vmOf(wrapper, 'additional-properties').removeProperty('optional');
+ await nextTick();
+ expect(wrapper.vm.event.data).toEqual({ key: 1 });
+ });
+
+ it('allows structural edits when restrict is false', async () => {
+ const wrapper = mountEditor(
+ { key: 1 },
+ {
+ type: 'object',
+ additionalProperties: { type: 'number' },
+ required: ['key'],
+ minProperties: 1,
+ maxProperties: 1,
+ },
+ { restrict: false },
+ );
+ const vm = vmOf(wrapper, 'additional-properties');
+ vm.newPropertyName = 'newKey';
+ vm.addProperty();
+ await nextTick();
+ expect(wrapper.vm.event.data).toEqual({ key: 1, newKey: 0 });
+ vm.removeProperty('key');
+ await nextTick();
+ expect(wrapper.vm.event.data).toEqual({ newKey: 0 });
+ });
+
+ it('refreshes delegated schemas and ownership without changing data keys', async () => {
+ const wrapper = mountEditor(
+ { key: 1, fixed: 2 },
+ { type: 'object', additionalProperties: { type: 'number' } },
+ );
+ const vm = vmOf(wrapper, 'additional-properties');
+ await wrapper.setProps({
+ schema: {
+ type: 'object',
+ properties: { fixed: { type: 'number' } },
+ additionalProperties: { type: 'string', minLength: 5 },
+ },
+ });
+ expect(vm.additionalPropertyItems.map((item) => item.propertyName)).toEqual(
+ ['key'],
+ );
+ expect(vm.additionalPropertyItems[0].schema).toMatchObject({
+ type: 'string',
+ minLength: 5,
+ });
+ expect(wrapper.vm.event.data).toEqual({ key: 1, fixed: 2 });
+ });
+});
+
+describe.each([false, true])('pending rename (tree=%s)', (tree) => {
+ it('discards a draft when external data replaces the same key', async () => {
+ const wrapper = mountEditor(
+ { key: 1 },
+ {
+ type: tree ? ['object', 'string'] : 'object',
+ additionalProperties: { type: 'number' },
+ },
+ );
+ const vm = vmOf(wrapper, tree ? 'mixed-renderer' : 'additional-properties');
+ if (tree) {
+ vm.toggleShowPrimitives();
+ await nextTick();
+ }
+ const node = tree
+ ? flattenTree(vm.treeNodes).find((node) => node.control.path === 'key')!
+ : undefined;
+ vm.startRename(node ?? 'key');
+ vm.renameValue = 'renamed';
+ await wrapper.setProps({ data: { key: 99 } });
+ // A late Enter/blur from the obsolete editor must not rename the replacement.
+ if (tree) vm.commitRename(node!);
+ else vm.renameProperty('key');
+ await nextTick();
+ expect(wrapper.vm.event.data).toEqual({ key: 99 });
+ expect(tree ? vm.renamingNodeId : vm.renamingPropertyName).toBeNull();
+ });
+});
+
+describe('MixedRenderer type editing', () => {
+ it('does not reset an already selected type', async () => {
+ const wrapper = mountEditor('keep me', {
+ type: ['string', 'number'],
+ default: 'default',
+ });
+ const vm = vmOf(wrapper, 'mixed-renderer');
+ vm.handleSelectChange(vm.selectedIndex);
+ await nextTick();
+ expect(wrapper.vm.event.data).toBe('keep me');
+ });
+
+ it.each([
+ { readonly: true },
+ { readonly: true, separateReadonlyFromDisabled: true },
+ ])('rechecks type-change permissions with %j', async (config) => {
+ const wrapper = mountEditor('keep me', { type: ['string', 'number'] });
+ const vm = vmOf(wrapper, 'mixed-renderer');
+ const numberIndex = vm.mixedRenderInfos.find(
+ (info) => info.resolvedSchema.type === 'number',
+ )!.index;
+ await wrapper.setProps({ config });
+ vm.handleSelectChange(numberIndex);
+ await nextTick();
+ expect(wrapper.vm.event.data).toBe('keep me');
+ });
+
+ it('prevents clearing a homogeneous array slot while allowing JSON null', async () => {
+ const wrapper = mountEditor(['keep'], {
+ type: 'array',
+ items: { type: ['string', 'number', 'null'] },
+ });
+ const mixed = wrapper.findComponent({ name: 'mixed-renderer' });
+ expect(mixed.exists()).toBe(true);
+ const vm = mixed.vm as unknown as EditorVM;
+ expect(mixed.findComponent(VSelect).props('clearable')).toBe(false);
+ vm.handleSelectChange(null);
+ await nextTick();
+ expect(wrapper.vm.event.data).toEqual(['keep']);
+ vm.handleSelectChange(
+ vm.mixedRenderInfos.find((info) => info.resolvedSchema.type === 'null')!
+ .index,
+ );
+ await nextTick();
+ expect(wrapper.vm.event.data).toEqual([null]);
+ });
+
+ it.each([
+ { value: { name: 'Ada' }, type: 'object' },
+ { value: null, type: 'null' },
+ ])(
+ 'allows changing the selected $type node using its original union',
+ async ({ value }) => {
+ const wrapper = mountEditor(
+ { child: value },
+ {
+ type: ['object', 'string'],
+ properties: {
+ child: {
+ type: ['object', 'string', 'null'],
+ properties: { name: { type: 'string' } },
+ },
+ },
+ },
+ );
+ const root = vmOf(wrapper, 'mixed-renderer');
+ root.toggleShowPrimitives();
+ await nextTick();
+ root.activatedTreeNodes = ['$path:child'];
+ await nextTick();
+ const detail = wrapper.find('.mixed-detail-pane');
+ const selected = detail.findComponent({ name: 'mixed-renderer' });
+ expect(selected.exists()).toBe(true);
+ expect(detail.find('.mixed-nested-complex').exists()).toBe(false);
+ const vm = selected.vm as unknown as EditorVM;
+ vm.handleSelectChange(
+ vm.mixedRenderInfos.find(
+ (info) => info.resolvedSchema.type === 'string',
+ )!.index,
+ );
+ await nextTick();
+ expect(wrapper.vm.event.data).toEqual({ child: '' });
+ },
+ );
+
+ it('uses object-detail in the workspace root instead of generating every field', () => {
+ const wrapper = mountEditor(
+ { shown: 'visible', hidden: 'secret' },
+ {
+ type: ['object', 'string'],
+ properties: { shown: { type: 'string' }, hidden: { type: 'string' } },
+ },
+ {},
+ {
+ type: 'Control',
+ scope: '#',
+ options: {
+ 'object-detail': {
+ type: 'VerticalLayout',
+ elements: [
+ {
+ type: 'Control',
+ scope: '#/properties/shown',
+ label: 'Chosen field',
+ },
+ ],
+ },
+ },
+ },
+ );
+ const detail = wrapper.find('.mixed-detail-pane');
+ expect(detail.text()).toContain('Chosen field');
+ expect(
+ detail.findAll('input').map((input) => input.element.value),
+ ).not.toContain('secret');
+ });
+});
+
+it('enforces parent constraints from every matching pattern, including references', () => {
+ const schema: JsonSchema = {
+ type: 'object',
+ patternProperties: {
+ '^parent': { type: 'object' },
+ parent$: { $ref: '#/definitions/limits' },
+ },
+ definitions: {
+ limits: {
+ type: 'object',
+ required: ['key'],
+ minProperties: 2,
+ propertyNames: { pattern: '^key' },
+ },
+ },
+ };
+ const nodes = flattenTree(
+ buildTreeFromData(
+ { parent: { key: 1, keyOther: 2 } },
+ schema,
+ schema,
+ '',
+ '',
+ true,
+ false,
+ true,
+ (index) => `${index}`,
+ true,
+ ),
+ );
+ expect(
+ nodes.find((node) => node.control.path === 'parent.key'),
+ ).toMatchObject({ canRename: false, canDelete: false });
+ expect(
+ nodes.find((node) => node.control.path === 'parent.keyOther')?.canDelete,
+ ).toBe(false);
+ expect(
+ nodes.find((node) => node.control.path === 'parent')?.control.schema,
+ ).toHaveProperty('propertyNames');
+});
+
+describe.each([false, true])(
+ 'pending rename during array reorder (tree=%s)',
+ (tree) => {
+ it('does not apply an obsolete rename to the new item at index zero', async () => {
+ const wrapper = mountEditor([{ key: 1 }, { key: 2 }], {
+ type: tree ? ['array', 'string'] : 'array',
+ items: { type: 'object', additionalProperties: { type: 'number' } },
+ });
+ const vm = vmOf(
+ wrapper,
+ tree ? 'mixed-renderer' : 'additional-properties',
+ );
+ if (tree) {
+ vm.toggleShowPrimitives();
+ await nextTick();
+ }
+ const node = tree
+ ? flattenTree(vm.treeNodes).find(
+ (node) => node.control.path === '0.key',
+ )!
+ : undefined;
+ vm.startRename(node ?? 'key');
+ vm.renameValue = 'renamed';
+ await wrapper.setProps({ data: [{ key: 2 }, { key: 1 }] });
+ if (tree) vm.commitRename(node!);
+ else vm.renameProperty('key');
+ await nextTick();
+ expect(wrapper.vm.event.data).toEqual([{ key: 2 }, { key: 1 }]);
+ });
+ },
+);
+
+it('refreshes dynamic editors when only the referenced root definition changes', async () => {
+ const schema: JsonSchema = {
+ type: 'object',
+ additionalProperties: { $ref: '#/definitions/value' },
+ definitions: { value: { type: 'number' } },
+ };
+ const wrapper = mountEditor({ key: 1 }, schema);
+ const vm = vmOf(wrapper, 'additional-properties');
+ const updatedSchema: JsonSchema = {
+ ...schema,
+ definitions: { value: { type: 'string' } },
+ };
+ await wrapper.setProps({ schema: updatedSchema });
+ expect(vm.additionalPropertyItems[0].schema.type).toBe('string');
+ expect(wrapper.vm.event.data).toEqual({ key: 1 });
+});
+
+it('gives object-detail precedence over general detail at a non-root scope', () => {
+ const wrapper = mountEditor(
+ { profile: { shown: 'visible', hidden: 'secret' } },
+ {
+ type: 'object',
+ properties: {
+ profile: {
+ type: ['object', 'string'],
+ properties: { shown: { type: 'string' }, hidden: { type: 'string' } },
+ },
+ },
+ },
+ {},
+ {
+ type: 'Control',
+ scope: '#/properties/profile',
+ options: {
+ detail: {
+ type: 'VerticalLayout',
+ elements: [{ type: 'Control', scope: '#/properties/hidden' }],
+ },
+ 'object-detail': {
+ type: 'VerticalLayout',
+ elements: [
+ {
+ type: 'Control',
+ scope: '#/properties/shown',
+ label: 'Chosen field',
+ },
+ ],
+ },
+ },
+ },
+ );
+ const detail = wrapper.find('.mixed-detail-pane');
+ expect(detail.text()).toContain('Chosen field');
+ expect(
+ detail.findAll('input').map((input) => input.element.value),
+ ).not.toContain('secret');
+});
+
+it('preserves a nested control’s object-detail when selecting it directly in the tree', async () => {
+ const wrapper = mountEditor(
+ { child: { shown: 'visible', hidden: 'secret' } },
+ {
+ type: ['object', 'string'],
+ properties: {
+ child: {
+ type: ['object', 'string'],
+ properties: { shown: { type: 'string' }, hidden: { type: 'string' } },
+ },
+ },
+ },
+ {},
+ {
+ type: 'Control',
+ scope: '#',
+ options: {
+ 'object-detail': {
+ type: 'VerticalLayout',
+ elements: [
+ {
+ type: 'Control',
+ scope: '#/properties/child',
+ options: {
+ 'object-detail': {
+ type: 'VerticalLayout',
+ elements: [
+ {
+ type: 'Control',
+ scope: '#/properties/shown',
+ label: 'Nested chosen field',
+ },
+ ],
+ },
+ },
+ },
+ ],
+ },
+ },
+ },
+ );
+ vmOf(wrapper, 'mixed-renderer').activatedTreeNodes = ['$path:child'];
+ await nextTick();
+ const detail = wrapper.find('.mixed-detail-pane');
+ expect(detail.text()).toContain('Nested chosen field');
+ expect(
+ detail.findAll('input').map((input) => input.element.value),
+ ).not.toContain('secret');
+});
+
+it('validates rename against a referenced propertyNames from the second parent pattern', async () => {
+ const wrapper = mountEditor(
+ { parent: { key: 1 } },
+ {
+ type: ['object', 'string'],
+ patternProperties: {
+ '^parent': { type: 'object' },
+ parent$: {
+ type: 'object',
+ propertyNames: { $ref: '#/definitions/name' },
+ },
+ },
+ definitions: { name: { type: 'string', pattern: '^allowed' } },
+ },
+ );
+ const vm = vmOf(wrapper, 'mixed-renderer');
+ vm.toggleShowPrimitives();
+ await nextTick();
+ const node = flattenTree(vm.treeNodes).find(
+ (node) => node.control.path === 'parent.key',
+ )!;
+ vm.startRename(node);
+ vm.renameValue = 'wrong';
+ vm.commitRename(node);
+ await nextTick();
+ expect(wrapper.vm.event.data).toEqual({ parent: { key: 1 } });
+ vm.renameValue = 'allowedName';
+ vm.commitRename(node);
+ await nextTick();
+ expect(wrapper.vm.event.data).toEqual({ parent: { allowedName: 1 } });
+});
+
+it('renders one editor when several parent patterns constrain the same object', async () => {
+ const wrapper = mountEditor(
+ { parent: { key: 1 } },
+ {
+ type: ['object', 'string'],
+ patternProperties: {
+ '^parent': { type: 'object', additionalProperties: { type: 'number' } },
+ parent$: { type: 'object', minProperties: 1 },
+ },
+ },
+ );
+ vmOf(wrapper, 'mixed-renderer').activatedTreeNodes = ['$path:parent'];
+ await nextTick();
+ expect(
+ wrapper.find('.mixed-detail-pane').findAll('.additional-properties-card'),
+ ).toHaveLength(1);
+});
+
+it('keeps the renamed property selected and editable when search hides its row', async () => {
+ const wrapper = mountEditor(
+ { customer: { name: 'Ada' } },
+ {
+ type: ['object', 'string'],
+ additionalProperties: {
+ type: 'object',
+ properties: { name: { type: 'string' } },
+ },
+ },
+ );
+ const vm = vmOf(wrapper, 'mixed-renderer');
+ vm.treeSearch = 'customer';
+ vm.activatedTreeNodes = ['$path:customer'];
+ await nextTick();
+ const node = flattenTree(vm.treeNodes).find(
+ (node) => node.control.path === 'customer',
+ )!;
+ vm.startRename(node);
+ vm.renameValue = 'client';
+ vm.commitRename(node);
+ await flushPromises();
+ expect(vm.treeSearch).toBe('customer');
+ expect(vm.activatedTreeNodes).toEqual(['$path:client']);
+ expect(wrapper.find('.mixed-detail-pane').find('input').element.value).toBe(
+ 'Ada',
+ );
+ // Vuetify hides filtered rows through its stylesheet, which jsdom does not load.
+ const renamedTitle = wrapper
+ .findAll('.mixed-tree-title')
+ .find((title) => title.text() === 'client')!;
+ expect(
+ renamedTitle.element.closest('.v-treeview-item--filtered'),
+ ).not.toBeNull();
+ await wrapper.find('.mixed-detail-pane').find('input').setValue('Grace');
+ await wrapper.find('.mixed-detail-pane').find('input').trigger('blur');
+ await flushPromises();
+ expect(wrapper.vm.event.data).toEqual({ client: { name: 'Grace' } });
+ vm.treeSearch = '';
+ await flushPromises();
+ expect(vm.activatedTreeNodes).toEqual(['$path:client']);
+ expect(
+ wrapper
+ .findAll('.mixed-tree-title')
+ .find((title) => title.text() === 'client')!
+ .element.closest('.v-treeview-item--filtered'),
+ ).toBeNull();
+});
+
+it('keeps the selected detail when the primitive toggle hides its row', async () => {
+ const wrapper = mountEditor(
+ { value: 'keep editing' },
+ {
+ type: ['object', 'string'],
+ additionalProperties: { type: 'string' },
+ },
+ );
+ const vm = vmOf(wrapper, 'mixed-renderer');
+ vm.toggleShowPrimitives();
+ await nextTick();
+ vm.activatedTreeNodes = ['$path:value'];
+ await nextTick();
+ vm.toggleShowPrimitives();
+ await flushPromises();
+ expect(vm.activatedTreeNodes).toEqual(['$path:value']);
+ expect(
+ flattenTree(vm.treeNodes).some((node) => node.control.path === 'value'),
+ ).toBe(false);
+ expect(wrapper.find('.mixed-detail-pane').find('input').element.value).toBe(
+ 'keep editing',
+ );
+ // Actual removal, unlike filtering, must reconcile to a surviving node.
+ await wrapper.setProps({ data: {} });
+ expect(vm.activatedTreeNodes).toEqual(['$root']);
+});
+
+it('keeps the detail panel after a selected object becomes a hidden primitive', async () => {
+ const wrapper = mountEditor(
+ { child: {} },
+ {
+ type: ['object', 'string'],
+ additionalProperties: { type: ['object', 'string'] },
+ },
+ );
+ const root = vmOf(wrapper, 'mixed-renderer');
+ root.activatedTreeNodes = ['$path:child'];
+ await nextTick();
+ const selected = wrapper
+ .find('.mixed-detail-pane')
+ .findComponent({ name: 'mixed-renderer' }).vm as unknown as EditorVM;
+ selected.handleSelectChange(
+ selected.mixedRenderInfos.find(
+ (info) => info.resolvedSchema.type === 'string',
+ )!.index,
+ );
+ await flushPromises();
+ expect(root.activatedTreeNodes).toEqual(['$path:child']);
+ expect(
+ flattenTree(root.treeNodes).some((node) => node.control.path === 'child'),
+ ).toBe(false);
+ expect(
+ wrapper.find('.mixed-detail-pane').find('.mixed-primitive').exists(),
+ ).toBe(true);
+ expect(wrapper.vm.event.data).toEqual({ child: '' });
+});
+
+describe('MixedRenderer selection after deletion', () => {
+ it.each([
+ {
+ data: ['Alice', 'Bob', 'Carol'],
+ selected: '2',
+ deleted: '0',
+ expected: '1',
+ value: 'Carol',
+ },
+ {
+ data: ['Alice', 'Bob', 'Carol'],
+ selected: '1',
+ deleted: '0',
+ expected: '0',
+ value: 'Bob',
+ },
+ {
+ data: { people: [{ name: 'Alice' }, { name: 'Bob' }, { name: 'Carol' }] },
+ selected: 'people.2.name',
+ deleted: 'people.0',
+ expected: 'people.1.name',
+ value: 'Carol',
+ },
+ {
+ data: ['Alice', 'Bob', 'Carol'],
+ selected: '0',
+ deleted: '2',
+ expected: '0',
+ value: 'Alice',
+ },
+ {
+ data: { item: 1, itemCode: 'keep' },
+ selected: 'itemCode',
+ deleted: 'item',
+ expected: 'itemCode',
+ value: 'keep',
+ },
+ // Index 10 is a sibling of index 1, and shifts to 9 rather than selecting the parent.
+ {
+ data: Array.from({ length: 11 }, (_, i) => `Item ${i}`),
+ selected: '10',
+ deleted: '1',
+ expected: '9',
+ value: 'Item 10',
+ },
+ {
+ data: { parent: { child: 1, keep: 2 } },
+ selected: 'parent.child',
+ deleted: 'parent.child',
+ expected: 'parent',
+ value: { keep: 2 },
+ },
+ {
+ data: { parent: { child: { name: 'Alice' }, keep: 2 } },
+ selected: 'parent.child.name',
+ deleted: 'parent.child',
+ expected: 'parent',
+ value: { keep: 2 },
+ },
+ ])(
+ 'keeps the correct target when deleting $deleted with $selected selected',
+ async ({ data, selected, deleted, expected, value }) => {
+ const wrapper = mountEditor(data, { type: ['object', 'array', 'null'] });
+ const vm = vmOf(wrapper, 'mixed-renderer');
+ vm.toggleShowPrimitives();
+ await nextTick();
+ vm.activatedTreeNodes = [`$path:${selected}`];
+ await nextTick();
+ const node = flattenTree(vm.treeNodes).find(
+ (node) => node.control.path === deleted,
+ )!;
+ vm.deleteNode(node);
+ vm.confirmDelete();
+ await nextTick();
+ expect(vm.activatedTreeNodes).toEqual([`$path:${expected}`]);
+ expect(vm.selectedNode.control.path).toBe(expected);
+ const selectedValue = expected
+ .split('.')
+ .reduce((data, key) => data[key], wrapper.vm.event.data);
+ expect(selectedValue).toEqual(value);
+ },
+ );
+});
+
+describe('MixedRenderer safe tree mutations', () => {
+ it('does not delete a nested property through a literal dotted key', async () => {
+ const data = { 'a.b': 1, a: { b: 2 } };
+ const wrapper = mountEditor(data, { type: ['object', 'null'] });
+ const vm = vmOf(wrapper, 'mixed-renderer');
+ vm.toggleShowPrimitives();
+ await nextTick();
+ const nodes = flattenTree(vm.treeNodes);
+ const literal = nodes.find((node) => node.label === 'a.b')!;
+ vm.deleteNode(literal);
+ await nextTick();
+ expect(wrapper.vm.event.data).toEqual({ a: { b: 2 } });
+ expect(new Set(nodes.map((node) => node.nodeId)).size).toBe(nodes.length);
+ expect(literal.canRename).toBe(true);
+ });
+
+ it.each(['object', 'array'] as const)(
+ 'inherits readOnly through a referenced %s schema',
+ async (type) => {
+ const data = { locked: type === 'object' ? { child: 1 } : [1] };
+ const wrapper = mountEditor(data, {
+ type: ['object', 'null'],
+ properties: { locked: { $ref: '#/definitions/locked' } },
+ definitions: { locked: { type, readOnly: true } },
+ });
+ const vm = vmOf(wrapper, 'mixed-renderer');
+ vm.toggleShowPrimitives();
+ await nextTick();
+ const nodes = flattenTree(vm.treeNodes).filter((node) =>
+ node.control.path.startsWith('locked'),
+ );
+ for (const node of nodes) {
+ vm.deleteNode(node);
+ vm.confirmDelete();
+ }
+ await nextTick();
+ expect(wrapper.vm.event.data).toEqual(data);
+ expect(
+ nodes.every(
+ (node) => !node.canDelete && !node.canRename && node.control.readonly,
+ ),
+ ).toBe(true);
+ },
+ );
+
+ it('preserves literal brackets when deleting a nested property', async () => {
+ const wrapper = mountEditor(
+ { 'a[0]': { child: 1 }, a: [{ child: 2 }] },
+ { type: ['object', 'null'] },
+ );
+ const vm = vmOf(wrapper, 'mixed-renderer');
+ vm.toggleShowPrimitives();
+ await nextTick();
+ vm.deleteNode(
+ flattenTree(vm.treeNodes).find(
+ (node) => node.control.path === 'a[0].child',
+ )!,
+ );
+ await nextTick();
+ expect(wrapper.vm.event.data).toEqual({ 'a[0]': {}, a: [{ child: 2 }] });
+ });
+});
+
+it.each(['a.b', ''])(
+ 'edits the literal property %j while allowing rename and delete',
+ async (key) => {
+ const data = { [key]: 'literal value', a: { b: 'nested value' } };
+ const wrapper = mountEditor(data, {
+ type: 'object',
+ additionalProperties: true,
+ });
+ await nextTick();
+ const row = wrapper.find('.additional-property-row');
+ const input = row
+ .findAll('input')
+ .find(
+ (input) =>
+ (input.element as HTMLInputElement).value === 'literal value',
+ )!;
+ expect(input.exists()).toBe(true);
+ expect((input.element as HTMLInputElement).value).toBe('literal value');
+ expect(
+ (input.element as HTMLInputElement).readOnly ||
+ (input.element as HTMLInputElement).disabled,
+ ).toBe(false);
+ await input.setValue('edited');
+ // String controls debounce writes for 300 ms.
+ await vi.waitFor(() =>
+ expect(wrapper.vm.event.data).toEqual({
+ [key]: 'edited',
+ a: { b: 'nested value' },
+ }),
+ );
+ const vm = vmOf(wrapper, 'additional-properties');
+ vm.startRename(key);
+ vm.renameValue = 'renamed';
+ vm.renameProperty(key);
+ await nextTick();
+ expect(wrapper.vm.event.data).toEqual({
+ renamed: 'edited',
+ a: { b: 'nested value' },
+ });
+ vm.removeProperty('renamed');
+ await nextTick();
+ expect(wrapper.vm.event.data).toEqual({ a: { b: 'nested value' } });
+ },
+);
+
+it('adds and renames dotted properties without creating nested paths', async () => {
+ const wrapper = mountEditor(
+ {},
+ {
+ type: 'object',
+ additionalProperties: { type: 'string', default: 'new' },
+ },
+ );
+ const vm = vmOf(wrapper, 'additional-properties');
+ vm.newPropertyName = 'a.b';
+ vm.addProperty();
+ await nextTick();
+ expect(wrapper.vm.event.data).toEqual({ 'a.b': 'new' });
+ vm.startRename('a.b');
+ vm.renameValue = 'c.d';
+ vm.renameProperty('a.b');
+ await nextTick();
+ expect(wrapper.vm.event.data).toEqual({ 'c.d': 'new' });
+});
+
+it('preserves nested schema references and validation for a dotted property', async () => {
+ const schema: JsonSchema = {
+ type: 'object',
+ additionalProperties: { $ref: '#/definitions/value' },
+ definitions: {
+ value: {
+ type: 'object',
+ properties: { count: { $ref: '#/definitions/count' } },
+ },
+ count: { type: 'integer', minimum: 5 },
+ },
+ };
+ const wrapper = mountEditor({ 'a.b': { count: 6 } }, schema);
+ await nextTick();
+ const input = wrapper.find('.additional-property-content input');
+ expect((input.element as HTMLInputElement).value).toBe('6');
+ await input.setValue('2');
+ await flushPromises();
+ expect(wrapper.vm.event.data).toEqual({ 'a.b': { count: 2 } });
+ expect(
+ wrapper.vm.event.errors?.some((error) => error.keyword === 'minimum'),
+ ).toBe(true);
+});
+
+it.each([
+ { type: 'string', before: 'old', after: 'new' },
+ { type: 'integer', before: 1, after: 2 },
+ { type: 'number', before: 1.5, after: 2.5 },
+ { type: 'boolean', before: true, after: false },
+ { type: 'null', before: null, after: null },
+ { type: 'array', before: [1], after: [1, 2] },
+ { type: 'object', before: { child: 1 }, after: { child: 2 } },
+] as const)(
+ 'keeps the $type schema and literal key when forwarding an isolated form update',
+ async ({ type, before, after }) => {
+ const wrapper = mountEditor(
+ { 'a.b': before },
+ { type: 'object', additionalProperties: { type } },
+ );
+ await nextTick();
+ const form = wrapper
+ .find('.additional-property-content')
+ .findComponent({ name: 'JsonForms' });
+ expect(form.props('schema').type).toBe(type);
+ // Exercise the bridge independently of each renderer's input widgets.
+ form.vm.$emit('change', { data: after, errors: [] });
+ await nextTick();
+ expect(wrapper.vm.event.data).toEqual({ 'a.b': after });
+ },
+);
+
+it('ignores literal-value changes when the parent is read-only', async () => {
+ const wrapper = mountEditor(
+ { 'a.b': 'keep' },
+ { type: 'object', additionalProperties: { type: 'string' } },
+ { readonly: true },
+ );
+ await nextTick();
+ const form = wrapper
+ .find('.additional-property-content')
+ .findComponent({ name: 'JsonForms' });
+ expect(form.props('readonly')).toBe(true);
+ form.vm.$emit('change', { data: 'discard', errors: [] });
+ await nextTick();
+ expect(wrapper.vm.event.data).toEqual({ 'a.b': 'keep' });
+});
+
+it('adds an empty property name and prevents overwriting it', async () => {
+ const wrapper = mountEditor(
+ {},
+ {
+ type: 'object',
+ additionalProperties: { type: 'string', default: 'new' },
+ },
+ { allowEmptyPropertyNames: true },
+ );
+ const vm = vmOf(wrapper, 'additional-properties');
+ await nextTick();
+ vm.newPropertyName = '';
+ vm.addProperty();
+ await nextTick();
+ expect(wrapper.vm.event.data).toEqual({ '': 'new' });
+ await wrapper.setProps({ data: { '': 'keep' } });
+ vm.addProperty();
+ await nextTick();
+ expect(wrapper.vm.event.data).toEqual({ '': 'keep' });
+});
+
+it('renames to an empty property name', async () => {
+ const wrapper = mountEditor(
+ { original: 1 },
+ { type: 'object', additionalProperties: { type: 'number' } },
+ { allowEmptyPropertyNames: true },
+ );
+ const vm = vmOf(wrapper, 'additional-properties');
+ vm.startRename('original');
+ vm.renameValue = '';
+ vm.renameProperty('original');
+ await nextTick();
+ expect(wrapper.vm.event.data).toEqual({ '': 1 });
+});
+
+it('applies propertyNames constraints to the empty string', async () => {
+ const wrapper = mountEditor(
+ { original: 1 },
+ {
+ type: 'object',
+ propertyNames: { minLength: 1 },
+ additionalProperties: { type: 'number' },
+ },
+ );
+ const vm = vmOf(wrapper, 'additional-properties');
+ vm.newPropertyName = '';
+ vm.addProperty();
+ vm.startRename('original');
+ vm.renameValue = '';
+ vm.renameProperty('original');
+ await nextTick();
+ expect(wrapper.vm.event.data).toEqual({ original: 1 });
+});
+
+describe('mixed literal-key selected editors', () => {
+ it.each(['a.b', ''])(
+ 'edits nested literal key %j without changing siblings',
+ async (key) => {
+ const wrapper = mountEditor(
+ { holder: { [key]: 'Original' }, sibling: 'Untouched' },
+ { type: ['object', 'null'] },
+ );
+ const vm = vmOf(wrapper, 'mixed-renderer');
+ vm.toggleShowPrimitives();
+ await nextTick();
+ const node = flattenTree(vm.treeNodes).find(
+ (n) =>
+ n.control.path !== 'holder' &&
+ (key === '' ? n.label === '""' : n.label === key),
+ )!;
+ expect(node).toBeTruthy();
+ vm.activatedTreeNodes = [node.nodeId];
+ await nextTick();
+ const form = wrapper
+ .find('.mixed-detail-pane')
+ .findComponent({ name: 'JsonForms' });
+ expect(form.exists()).toBe(true);
+ const field = wrapper
+ .find('.mixed-detail-pane')
+ .findAll('input')
+ .find((i) => (i.element as HTMLInputElement).value === 'Original')!;
+ expect(field).toBeTruthy();
+ await field.setValue('Updated');
+ await vi.waitFor(() =>
+ expect(wrapper.vm.event.data).toEqual({
+ holder: { [key]: 'Updated' },
+ sibling: 'Untouched',
+ }),
+ );
+ },
+ );
+});
+
+it.each(['', 'new.name', ' spaced '])(
+ 'renames a mixed dotted key to exact name %j and keeps selection',
+ async (name) => {
+ const wrapper = mountEditor(
+ { 'a.b': 'Original', a: { b: 'Nested' } },
+ { type: ['object', 'null'] },
+ { allowEmptyPropertyNames: true },
+ );
+ const vm = vmOf(wrapper, 'mixed-renderer');
+ vm.toggleShowPrimitives();
+ await nextTick();
+ const node = flattenTree(vm.treeNodes).find((n) => n.label === 'a.b')!;
+ vm.activatedTreeNodes = [node.nodeId];
+ await nextTick();
+ vm.startRename(node);
+ vm.renameValue = name;
+ vm.commitRename(node);
+ await nextTick();
+ expect(wrapper.vm.event.data).toEqual({
+ [name]: 'Original',
+ a: { b: 'Nested' },
+ });
+ expect(vm.selectedNode.label).toBe(name === '' ? '""' : name);
+ const field = wrapper
+ .find('.mixed-detail-pane')
+ .findAll('input')
+ .find((i) => (i.element as HTMLInputElement).value === 'Original')!;
+ await field.setValue('Updated');
+ await vi.waitFor(() =>
+ expect(wrapper.vm.event.data).toEqual({
+ [name]: 'Updated',
+ a: { b: 'Nested' },
+ }),
+ );
+ },
+);
+it('edits the innermost empty key without inheriting parent name constraints', async () => {
+ const wrapper = mountEditor(
+ { '': { asd: { '': 'Original' } } },
+ {
+ type: 'object',
+ additionalProperties: {
+ type: 'object',
+ propertyNames: { minLength: 1 },
+ additionalProperties: true,
+ },
+ },
+ );
+ const vm = vmOf(wrapper, 'mixed-renderer');
+ vm.toggleShowPrimitives();
+ await nextTick();
+ const node = flattenTree(vm.treeNodes).find((n) => n.label === '""')!;
+ vm.activatedTreeNodes = [node.nodeId];
+ await nextTick();
+ const field = wrapper
+ .find('.mixed-detail-pane')
+ .findAll('input')
+ .find((i) => (i.element as HTMLInputElement).value === 'Original')!;
+ await field.setValue('Updated');
+ await vi.waitFor(() =>
+ expect(wrapper.vm.event.data).toEqual({ '': { asd: { '': 'Updated' } } }),
+ );
+ expect(wrapper.vm.event.errors).toEqual([]);
+});
+it('changes the type of a literal-key selected value without changing its key', async () => {
+ const wrapper = mountEditor({ 'a.b': {} }, { type: ['object', 'null'] });
+ const vm = vmOf(wrapper, 'mixed-renderer');
+ const node = flattenTree(vm.treeNodes).find((n) => n.label === 'a.b')!;
+ vm.activatedTreeNodes = [node.nodeId];
+ await nextTick();
+ const detail = wrapper
+ .find('.mixed-detail-pane')
+ .findComponent({ name: 'mixed-renderer' }).vm as unknown as EditorVM;
+ detail.handleSelectChange(
+ detail.mixedRenderInfos.find((i) => i.resolvedSchema.type === 'string')!
+ .index,
+ );
+ await nextTick();
+ expect(wrapper.vm.event.data).toEqual({ 'a.b': '' });
+});
+it('rechecks readonly ancestors for literal-key updates and mutations', async () => {
+ const data = { 'a.b': { leaf: 'Original' } };
+ const wrapper = mountEditor(data, {
+ type: ['object', 'null'],
+ properties: { 'a.b': { type: 'object', readOnly: true } },
+ });
+ const vm = vmOf(wrapper, 'mixed-renderer');
+ vm.toggleShowPrimitives();
+ await nextTick();
+ const node = flattenTree(vm.treeNodes).find((n) => n.label === 'leaf')!;
+ expect(node.canDelete).toBe(false);
+ expect(node.canRename).toBe(false);
+ vm.activatedTreeNodes = [node.nodeId];
+ await nextTick();
+ const form = wrapper
+ .find('.mixed-detail-pane')
+ .findComponent({ name: 'JsonForms' });
+ expect(form.props('readonly')).toBe(true);
+ form.vm.$emit('change', { data: 'Forbidden', errors: [] });
+ vm.deleteNode(node);
+ vm.confirmDelete();
+ await nextTick();
+ expect(wrapper.vm.event.data).toEqual(data);
+});
+it('honors minProperties on a literal parent when restrict is enabled', async () => {
+ const data = { 'a.b': { leaf: 'Original' } };
+ const wrapper = mountEditor(
+ data,
+ {
+ type: ['object', 'null'],
+ additionalProperties: { type: 'object', minProperties: 1 },
+ },
+ { restrict: true },
+ );
+ const vm = vmOf(wrapper, 'mixed-renderer');
+ vm.toggleShowPrimitives();
+ await nextTick();
+ const node = flattenTree(vm.treeNodes).find((n) => n.label === 'leaf')!;
+ expect(node.canDelete).toBe(false);
+ vm.deleteNode(node);
+ await nextTick();
+ expect(wrapper.vm.event.data).toEqual(data);
+});
+
+it.each([
+ { config: {}, options: {}, allowed: false },
+ { config: { allowEmptyPropertyNames: true }, options: {}, allowed: true },
+ {
+ config: { allowEmptyPropertyNames: true },
+ options: { allowEmptyPropertyNames: false },
+ allowed: false,
+ },
+ { config: {}, options: { allowEmptyPropertyNames: true }, allowed: true },
+])(
+ 'applies the empty-name policy with $config and $options',
+ async ({ config, options, allowed }) => {
+ const wrapper = mountEditor(
+ { old: 1 },
+ { type: 'object', additionalProperties: { type: 'number' } },
+ config,
+ { type: 'Control', scope: '#', options },
+ );
+ const vm = vmOf(wrapper, 'additional-properties');
+ vm.newPropertyName = '';
+ vm.addProperty();
+ await nextTick();
+ expect(
+ Object.prototype.hasOwnProperty.call(wrapper.vm.event.data, ''),
+ ).toBe(allowed);
+ vm.startRename('old');
+ vm.renameValue = ' ';
+ vm.renameProperty('old');
+ await nextTick();
+ expect(
+ Object.prototype.hasOwnProperty.call(wrapper.vm.event.data, ' '),
+ ).toBe(allowed);
+ },
+);
+
+it.each([
+ { config: {}, options: {}, allowed: false },
+ { config: { allowEmptyPropertyNames: true }, options: {}, allowed: true },
+ {
+ config: { allowEmptyPropertyNames: true },
+ options: { allowEmptyPropertyNames: false },
+ allowed: false,
+ },
+ { config: {}, options: { allowEmptyPropertyNames: true }, allowed: true },
+])(
+ 'applies the empty-name policy to mixed-tree rename with $config and $options',
+ async ({ config, options, allowed }) => {
+ const wrapper = mountEditor(
+ { old: 1 },
+ { type: ['object', 'null'] },
+ config,
+ { type: 'Control', scope: '#', options },
+ );
+ const vm = vmOf(wrapper, 'mixed-renderer');
+ vm.toggleShowPrimitives();
+ await nextTick();
+ const node = flattenTree(vm.treeNodes).find(
+ (node) => node.label === 'old',
+ )!;
+ vm.startRename(node);
+ vm.renameValue = '';
+ vm.commitRename(node);
+ await nextTick();
+ expect(wrapper.vm.event.data).toEqual(allowed ? { '': 1 } : { old: 1 });
+ },
+);
+
+it('keeps an untouched or reset property-name field free of errors while guarding Add', async () => {
+ const wrapper = mountEditor(
+ { existing: 'keep' },
+ { type: 'object', additionalProperties: { type: 'string' } },
+ );
+ await flushPromises();
+ const field = wrapper.find('.additional-properties-add-field');
+ const add = wrapper.find('.additional-properties-add button');
+ expect(field.text()).not.toContain('Property name is invalid');
+ expect(add.attributes('disabled')).toBeDefined();
+ const input = field.find('input');
+ await input.setValue('existing');
+ await vi.waitFor(() => expect(field.find('.v-messages').text()).not.toBe(''));
+ await input.setValue('new');
+ await vi.waitFor(() => expect(add.attributes('disabled')).toBeUndefined());
+ await add.trigger('click');
+ await flushPromises();
+ expect(wrapper.vm.event.data).toEqual({ existing: 'keep', new: '' });
+ expect(field.text()).not.toContain('Property name is invalid');
+ expect(add.attributes('disabled')).toBeDefined();
+});
+
+it.each([true, false, { type: 'string' }])(
+ 'combines all dynamic property patterns with fallback %j',
+ async (additionalProperties) => {
+ for (const reverse of [false, true]) {
+ const entries: [string, JsonSchema][] = [
+ ['^price_', { type: 'number', minimum: 0 }],
+ ['_total$', { maximum: 1000 }],
+ ];
+ const wrapper = mountEditor(
+ { price_total: 500 },
+ {
+ type: 'object',
+ patternProperties: Object.fromEntries(
+ reverse ? entries.reverse() : entries,
+ ),
+ additionalProperties,
+ },
+ { restrict: true },
+ );
+ await nextTick();
+ const property = vmOf(wrapper, 'additional-properties')
+ .additionalPropertyItems[0];
+ expect(property.schema).toMatchObject({
+ type: 'number',
+ minimum: 0,
+ maximum: 1000,
+ });
+ const input = wrapper
+ .findAll('input')
+ .find((field) => field.element.value === '500');
+ expect(input).toBeDefined();
+ await input!.setValue('1001');
+ await vi.waitFor(() =>
+ expect(wrapper.vm.event.data).toEqual({ price_total: 1001 }),
+ );
+ expect(wrapper.vm.event.errors).toEqual(
+ expect.arrayContaining([
+ expect.objectContaining({
+ keyword: 'maximum',
+ instancePath: '/price_total',
+ }),
+ ]),
+ );
+ }
+ },
+);
diff --git a/packages/vue-vuetify/tests/unit/util/additionalPropertySchema.spec.ts b/packages/vue-vuetify/tests/unit/util/additionalPropertySchema.spec.ts
new file mode 100644
index 0000000000..1ac7149dc0
--- /dev/null
+++ b/packages/vue-vuetify/tests/unit/util/additionalPropertySchema.spec.ts
@@ -0,0 +1,83 @@
+import { describe, expect, it } from 'vitest';
+import { additionalPropertySchema } from '../../../src/util/additionalPropertySchema';
+import type { JsonSchema, JsonSchema7 } from '@jsonforms/core';
+
+describe('dynamic property schema selection', () => {
+ it('collects every match and takes the strongest compatible bounds regardless of order', () => {
+ const entries: [string, JsonSchema7][] = [
+ ['^price_', { type: 'number', minimum: 0, maximum: 2000 }],
+ ['_total$', { minimum: 10, maximum: 1000 }],
+ ];
+ for (const pairs of [entries, [...entries].reverse()]) {
+ const schema: JsonSchema = {
+ patternProperties: Object.fromEntries(pairs),
+ additionalProperties: true,
+ };
+ expect(additionalPropertySchema('price_total', schema, schema)).toEqual({
+ type: 'number',
+ minimum: 10,
+ maximum: 1000,
+ });
+ }
+ });
+ it('uses fallback only without matches, including an empty regex', () => {
+ const schema: JsonSchema = {
+ patternProperties: { '^price_': { type: 'number' } },
+ additionalProperties: { type: 'string' },
+ };
+ expect(additionalPropertySchema('note', schema, schema)).toEqual({
+ type: 'string',
+ });
+ expect(additionalPropertySchema('price_unit', schema, schema)).toEqual({
+ type: 'number',
+ });
+ schema.patternProperties![''] = { maximum: 10 };
+ expect(additionalPropertySchema('price_unit', schema, schema)).toEqual({
+ type: 'number',
+ maximum: 10,
+ });
+ });
+ it('resolves referenced matching schemas without modifying the root', () => {
+ const schema: JsonSchema = {
+ definitions: { amount: { type: 'number', minimum: 0 } },
+ patternProperties: {
+ '^price_': { $ref: '#/definitions/amount' },
+ _total$: { maximum: 1000 },
+ },
+ };
+ const before = JSON.stringify(schema);
+ expect(additionalPropertySchema('price_total', schema, schema)).toEqual({
+ type: 'number',
+ minimum: 0,
+ maximum: 1000,
+ });
+ expect(JSON.stringify(schema)).toBe(before);
+ });
+ it.each([
+ [{ type: 'string' }, { type: 'number' }],
+ [
+ { type: 'object', properties: { x: { type: 'number' } } },
+ { additionalProperties: false },
+ ],
+ [{ type: 'string', pattern: '^a' }, { pattern: 'z$' }],
+ ])(
+ 'retains conflicting or structural schemas conjunctively',
+ (first, second) => {
+ const schema: JsonSchema = {
+ patternProperties: { '^': first, $: second },
+ };
+ const result = additionalPropertySchema('key', schema, schema);
+ expect(result.allOf).toHaveLength(2);
+ expect(result.allOf).toEqual(expect.arrayContaining([first, second]));
+ expect(result.type).toBeUndefined();
+ },
+ );
+ it('intersects number and integer', () => {
+ const schema: JsonSchema = {
+ patternProperties: { '^': { type: 'number' }, $: { type: 'integer' } },
+ };
+ expect(additionalPropertySchema('key', schema, schema)).toEqual({
+ type: 'integer',
+ });
+ });
+});
diff --git a/packages/vue-vuetify/tests/unit/util/dynamicProperties.spec.ts b/packages/vue-vuetify/tests/unit/util/dynamicProperties.spec.ts
new file mode 100644
index 0000000000..692beb5084
--- /dev/null
+++ b/packages/vue-vuetify/tests/unit/util/dynamicProperties.spec.ts
@@ -0,0 +1,414 @@
+import { createAjv } from '@jsonforms/core';
+import type { JsonSchema7 as JsonSchema } from '@jsonforms/core';
+import { describe, expect, it } from 'vitest';
+import {
+ canRenameDynamicProperty,
+ composePropertyPath,
+ findPropertySchema,
+ getDynamicPropertyNameErrorMessage,
+ getPathAncestorPaths,
+ getPropertyNameSchema,
+ haveAdditionalPropertyNamesChanged,
+ validateDynamicPropertyName,
+} from '../../../src/util/dynamicProperties';
+
+describe('dynamic property utilities', () => {
+ const rootSchema: JsonSchema = {
+ type: 'object',
+ definitions: {
+ propertyName: {
+ type: 'string',
+ pattern: '^[A-Z][A-Za-z0-9]*$',
+ },
+ patternValue: {
+ type: 'number',
+ },
+ },
+ };
+
+ describe('rename eligibility', () => {
+ it.each([
+ ['editable dynamic', {}, true],
+ ['readonly', { readonly: true }, false],
+ ['disabled', { enabled: false }, false],
+ [
+ 'required and restricted',
+ { schema: { required: ['key'] }, restrict: true },
+ false,
+ ],
+ ['required and unrestricted', { schema: { required: ['key'] } }, true],
+ [
+ 'declared false schema',
+ { schema: { properties: { key: false } } },
+ false,
+ ],
+ ['missing property', { data: {} }, false],
+ ['array index', { data: [1], propertyName: '0' }, false],
+ ])('%s', (_, overrides, expected) => {
+ expect(
+ canRenameDynamicProperty({
+ schema: {},
+ data: { key: 1 },
+ propertyName: 'key',
+ enabled: true,
+ readonly: false,
+ ...overrides,
+ } as Parameters[0]),
+ ).toBe(expected);
+ });
+ });
+
+ describe('getPathAncestorPaths', () => {
+ it('derives ancestors from an absolute nested control path', () => {
+ expect(getPathAncestorPaths('dynamic', 'dynamic.a.b')).toEqual([
+ 'dynamic',
+ 'dynamic.a',
+ ]);
+ });
+
+ it('supports root-scoped controls and selecting the root itself', () => {
+ expect(getPathAncestorPaths('', 'a.b')).toEqual(['', 'a']);
+ expect(getPathAncestorPaths('dynamic', 'dynamic')).toEqual(['dynamic']);
+ });
+ });
+
+ describe('findPropertySchema', () => {
+ it('prefers a matching pattern over additionalProperties: true', () => {
+ const schema: JsonSchema = {
+ type: 'object',
+ patternProperties: {
+ '^count': { $ref: '#/definitions/patternValue' },
+ },
+ additionalProperties: true,
+ };
+
+ expect(findPropertySchema(schema, 'countItems', rootSchema)).toEqual({
+ type: 'number',
+ });
+ });
+
+ it('falls back to the additional-properties schema', () => {
+ const schema: JsonSchema = {
+ type: 'object',
+ patternProperties: {
+ '^count': { type: 'number' },
+ },
+ additionalProperties: { type: 'string' },
+ };
+
+ expect(findPropertySchema(schema, 'description', rootSchema)).toEqual({
+ type: 'string',
+ });
+ });
+ });
+
+ describe('getPropertyNameSchema', () => {
+ it('resolves a propertyNames $ref against the root schema', () => {
+ const schema = {
+ type: 'object',
+ propertyNames: { $ref: '#/definitions/propertyName' },
+ } as JsonSchema;
+
+ expect(getPropertyNameSchema(schema, rootSchema)).toEqual({
+ type: 'string',
+ allOf: [{ type: 'string', pattern: '^[A-Z][A-Za-z0-9]*$' }],
+ });
+ });
+
+ it('uses patternProperties as a name constraint only when additional properties are forbidden', () => {
+ const patternProperties = { '^allowed': { type: 'string' } };
+
+ expect(
+ getPropertyNameSchema(
+ {
+ type: 'object',
+ patternProperties,
+ additionalProperties: false,
+ },
+ rootSchema,
+ ),
+ ).toEqual({
+ type: 'string',
+ allOf: [{ anyOf: [{ pattern: '^allowed' }] }],
+ });
+
+ expect(
+ getPropertyNameSchema(
+ {
+ type: 'object',
+ patternProperties,
+ additionalProperties: true,
+ },
+ rootSchema,
+ ),
+ ).toEqual({ type: 'string' });
+ });
+ });
+
+ describe('property name schema validation', () => {
+ it.each([
+ ['inline', { minLength: 5 }],
+ ['referenced', { $ref: '#/definitions/name' }],
+ ])(
+ 'combines %s propertyNames with allowed patterns',
+ (_, propertyNames) => {
+ const schema = {
+ type: 'object',
+ propertyNames,
+ additionalProperties: false,
+ patternProperties: {
+ '^foo_': { type: 'string' },
+ '^bar_': { type: 'number' },
+ },
+ } as JsonSchema;
+ const referencedRoot: JsonSchema = {
+ definitions: { name: { minLength: 5 } },
+ };
+ const propertyNameSchema = getPropertyNameSchema(
+ schema,
+ referencedRoot,
+ );
+ const ajv = createAjv();
+
+ for (const propertyName of ['foo_title', 'bar_count']) {
+ expect(
+ validateDynamicPropertyName({
+ propertyName,
+ data: {},
+ propertyNameSchema,
+ ajv,
+ }),
+ ).toBeNull();
+ }
+ // Each name satisfies one constraint but violates the other.
+ for (const propertyName of ['wrong_name', 'foo_']) {
+ expect(
+ validateDynamicPropertyName({
+ propertyName,
+ data: {},
+ propertyNameSchema,
+ ajv,
+ }),
+ ).toMatchObject({ reason: 'schema' });
+ }
+ },
+ );
+
+ it.each([
+ ['propertyNames false', { propertyNames: false }],
+ ['closed object without patterns', { additionalProperties: false }],
+ [
+ 'closed object with empty patterns',
+ {
+ additionalProperties: false,
+ patternProperties: {},
+ },
+ ],
+ [
+ 'incompatible propertyNames type',
+ { propertyNames: { type: 'number' } },
+ ],
+ ])('rejects dynamic names for %s', (_, schema) => {
+ const propertyNameSchema = getPropertyNameSchema(
+ schema as unknown as JsonSchema,
+ rootSchema,
+ );
+ expect(
+ validateDynamicPropertyName({
+ propertyName: 'anything',
+ data: {},
+ propertyNameSchema,
+ ajv: createAjv(),
+ }),
+ ).toMatchObject({ reason: 'schema' });
+ });
+
+ it('keeps capture groups and backreferences local to each pattern', () => {
+ const propertyNameSchema = getPropertyNameSchema(
+ {
+ additionalProperties: false,
+ patternProperties: {
+ '^(a)\\1$': { type: 'string' },
+ '^(b)\\1$': { type: 'string' },
+ },
+ },
+ rootSchema,
+ );
+ const ajv = createAjv();
+ for (const name of ['aa', 'bb']) {
+ expect(ajv.validate(propertyNameSchema, name)).toBe(true);
+ }
+ for (const name of ['a', 'b', 'ab']) {
+ expect(ajv.validate(propertyNameSchema, name)).toBe(false);
+ }
+ });
+
+ it('allows unrestricted names when propertyNames is true', () => {
+ const propertyNameSchema = getPropertyNameSchema(
+ {
+ propertyNames: true,
+ additionalProperties: true,
+ } as unknown as JsonSchema,
+ rootSchema,
+ );
+ expect(createAjv().validate(propertyNameSchema, 'anything')).toBe(true);
+ });
+ });
+
+ describe('validateDynamicPropertyName', () => {
+ it.each([undefined, {}, { other: 1 }])(
+ 'reserves declared names independently of data: %j',
+ (data) => {
+ expect(
+ validateDynamicPropertyName({
+ propertyName: 'fixed',
+ reservedPropertyNames: ['fixed'],
+ data,
+ }),
+ ).toEqual({ reason: 'alreadyDefined' });
+ },
+ );
+
+ it('does not reserve inherited Object prototype names', () => {
+ expect(
+ validateDynamicPropertyName({
+ propertyName: 'toString',
+ reservedPropertyNames: Object.keys({ fixed: false }),
+ data: {},
+ }),
+ ).toBeNull();
+ });
+
+ const ajv = createAjv();
+
+ it('rejects another existing property but allows the current name', () => {
+ const data = { first: 1, second: 2 };
+
+ expect(
+ validateDynamicPropertyName({ propertyName: 'second', data }),
+ ).toEqual({ reason: 'alreadyDefined' });
+ expect(
+ validateDynamicPropertyName({
+ propertyName: 'first',
+ currentPropertyName: 'first',
+ data,
+ }),
+ ).toBeNull();
+ });
+
+ it('rejects dots but permits bracket characters supported by core paths', () => {
+ expect(
+ validateDynamicPropertyName({ propertyName: 'nested.value', data: {} }),
+ ).toEqual({ reason: 'invalid' });
+ expect(
+ validateDynamicPropertyName({ propertyName: 'property[0]', data: {} }),
+ ).toBeNull();
+ expect(composePropertyPath('parent', '[property]')).toBe(
+ 'parent.[property]',
+ );
+ });
+
+ it('uses the same JSON Schema validation for both rename entry points', () => {
+ const propertyNameSchema = getPropertyNameSchema(
+ {
+ type: 'object',
+ propertyNames: { $ref: '#/definitions/propertyName' },
+ } as JsonSchema,
+ rootSchema,
+ );
+
+ expect(
+ validateDynamicPropertyName({
+ propertyName: 'invalid',
+ data: {},
+ propertyNameSchema,
+ ajv,
+ }),
+ ).toMatchObject({ reason: 'schema' });
+ expect(
+ validateDynamicPropertyName({
+ propertyName: 'ValidName',
+ data: {},
+ propertyNameSchema,
+ ajv,
+ }),
+ ).toBeNull();
+ });
+
+ it('allows JSON Schema errors to be translated by the caller', () => {
+ const validationError = validateDynamicPropertyName({
+ propertyName: 'invalid',
+ data: {},
+ propertyNameSchema: { type: 'string', pattern: '^[A-Z]' },
+ ajv,
+ });
+
+ expect(
+ getDynamicPropertyNameErrorMessage(validationError, {
+ alreadyDefined: 'Already defined',
+ invalid: 'Invalid',
+ schema: (error) => `Translated ${error.keyword} error`,
+ }),
+ ).toBe('Translated pattern error');
+ });
+ });
+
+ describe('haveAdditionalPropertyNamesChanged', () => {
+ it('does not rebuild property descriptors when only a value or its type changes', () => {
+ expect(
+ haveAdditionalPropertyNamesChanged(
+ { declared: 'same', dynamic: { nested: true } },
+ { declared: 'same', dynamic: 'text' },
+ ['declared'],
+ ),
+ ).toBe(false);
+ });
+
+ it('detects additional-property additions, removals, and order changes', () => {
+ expect(
+ haveAdditionalPropertyNamesChanged(
+ { first: 1, second: 2 },
+ { first: 1 },
+ [],
+ ),
+ ).toBe(true);
+ expect(
+ haveAdditionalPropertyNamesChanged(
+ { second: 2, first: 1 },
+ { first: 1, second: 2 },
+ [],
+ ),
+ ).toBe(true);
+ });
+ });
+});
+
+it.each(['', ' '])(
+ 'requires an explicit opt-in for blank name %j',
+ (propertyName) => {
+ expect(validateDynamicPropertyName({ propertyName, data: {} })).toEqual({
+ reason: 'invalid',
+ });
+ expect(
+ validateDynamicPropertyName({
+ propertyName,
+ data: {},
+ allowEmptyPropertyNames: true,
+ }),
+ ).toBeNull();
+ },
+);
+
+it('still validates schema constraints and collisions when empty names are enabled', () => {
+ const options = { propertyName: '', allowEmptyPropertyNames: true, data: {} };
+ expect(
+ validateDynamicPropertyName({
+ ...options,
+ propertyNameSchema: { type: 'string', minLength: 1 },
+ ajv: createAjv(),
+ })?.reason,
+ ).toBe('schema');
+ expect(
+ validateDynamicPropertyName({ ...options, data: { '': 1 } })?.reason,
+ ).toBe('alreadyDefined');
+});
diff --git a/packages/vue-vuetify/tests/unit/util/i18nUtil.spec.ts b/packages/vue-vuetify/tests/unit/util/i18nUtil.spec.ts
new file mode 100644
index 0000000000..9a07fee897
--- /dev/null
+++ b/packages/vue-vuetify/tests/unit/util/i18nUtil.spec.ts
@@ -0,0 +1,55 @@
+import { createTranslator } from '@jsonforms/core';
+import { describe, expect, it } from 'vitest';
+import { additionalPropertiesDefaultTranslations } from '../../../src/i18n/additionalPropertiesTranslations';
+import {
+ getAdditionalPropertyTranslation,
+ getMixedRendererTranslations,
+} from '../../../src/i18n/i18nUtil';
+import { AdditionalPropertiesTranslationEnum } from '../../../src/i18n';
+
+describe('Vuetify i18n utilities', () => {
+ it('uses translated Additional Properties action labels with their values', () => {
+ const translate = createTranslator((id, _defaultMessage, value) =>
+ id === 'control.additionalProperties.renameAriaLabel'
+ ? `Edit ${value}`
+ : undefined,
+ );
+
+ expect(
+ getAdditionalPropertyTranslation(
+ translate,
+ additionalPropertiesDefaultTranslations,
+ 'control.additionalProperties',
+ AdditionalPropertiesTranslationEnum.renameAriaLabel,
+ 'dynamicKey',
+ ),
+ ).toBe('Edit dynamicKey');
+ });
+
+ it('provides translated mixed tree strings and generated item labels', () => {
+ const translate = createTranslator((id, _defaultMessage, value) => {
+ if (id === 'control.mixedRenderer.searchLabel') {
+ return 'Find node';
+ }
+ if (id === 'control.mixedRenderer.itemLabel') {
+ return `Entry ${value}`;
+ }
+ if (id === 'control.mixedRenderer.renameAriaLabel') {
+ return `Edit ${value} button`;
+ }
+ return undefined;
+ });
+
+ const translations = getMixedRendererTranslations(
+ translate,
+ 'control.mixedRenderer',
+ );
+
+ expect(translations.searchLabel).toBe('Find node');
+ expect(translations.itemLabel(3)).toBe('Entry 3');
+ expect(translations.renameAriaLabel('dynamicKey')).toBe(
+ 'Edit dynamicKey button',
+ );
+ expect(translations.showPrimitives).toBe('Show primitives');
+ });
+});
diff --git a/packages/vue-vuetify/tests/unit/util/literalPropertySchema.spec.ts b/packages/vue-vuetify/tests/unit/util/literalPropertySchema.spec.ts
new file mode 100644
index 0000000000..da81d75413
--- /dev/null
+++ b/packages/vue-vuetify/tests/unit/util/literalPropertySchema.spec.ts
@@ -0,0 +1,22 @@
+import { describe, expect, it } from 'vitest';
+import { createAjv, type JsonSchema } from '@jsonforms/core';
+import { literalPropertySchema } from '../../../src/util/literalPropertySchema';
+
+describe('literal property schema', () => {
+ it('preserves recursive root references without rewriting data inside defaults', () => {
+ const root: JsonSchema = {
+ type: 'object',
+ properties: {
+ next: { $ref: '#' },
+ count: { type: 'integer', minimum: 1 },
+ },
+ };
+ const schema: JsonSchema = { $ref: '#', default: { $ref: '#', count: 1 } };
+ const isolated = literalPropertySchema(schema, root);
+ const validate = createAjv().compile(isolated);
+ expect(validate({ count: 1, next: { count: 2 } })).toBe(true);
+ expect(validate({ next: { count: 0 } })).toBe(false);
+ expect(isolated.default).toEqual(schema.default);
+ expect(root.properties?.next.$ref).toBe('#');
+ });
+});
diff --git a/packages/vue-vuetify/tests/unit/util/mixedLiteral.spec.ts b/packages/vue-vuetify/tests/unit/util/mixedLiteral.spec.ts
new file mode 100644
index 0000000000..08513bcfc7
--- /dev/null
+++ b/packages/vue-vuetify/tests/unit/util/mixedLiteral.spec.ts
@@ -0,0 +1,51 @@
+import { expect, it } from 'vitest';
+import {
+ encodeMixedSegment,
+ decodeMixedSegment,
+ mixedValueAt,
+ mixedHasUnsafeDeclaredScopes,
+ replaceMixedValue,
+} from '../../../src/util/mixedLiteral';
+it.each(['', 'a.b', 'a[0]', '\0', '\ud800.', '__proto__', ' spaced '])(
+ 'round-trips exact tree key %j without a path separator',
+ (key) => {
+ const id = encodeMixedSegment(key);
+ expect(id).not.toContain('.');
+ expect(decodeMixedSegment(id)).toBe(key);
+ },
+);
+it('separates dotted and nested keys and updates through arrays without mutation', () => {
+ const data = { 'a.b': [{ '': 1 }], a: { b: 2 } };
+ const updated = replaceMixedValue(data, ['a.b', '0', ''], 3);
+ expect(updated).toEqual({ 'a.b': [{ '': 3 }], a: { b: 2 } });
+ expect(mixedValueAt(data, ['a.b', '0', ''])).toBe(1);
+ expect(mixedValueAt(data, ['toString'])).toBeUndefined();
+ expect(replaceMixedValue(data, ['missing', 'child'], 4)).toBe(data);
+});
+it('updates prototype-named own keys safely', () => {
+ const data = JSON.parse('{"__proto__":{"x":1}}');
+ const updated = replaceMixedValue(data, ['__proto__', 'x'], 2);
+ expect(Object.getPrototypeOf(updated)).toBe(Object.prototype);
+ expect(updated.__proto__).toEqual({ x: 2 });
+ expect(Object.prototype).not.toHaveProperty('x');
+});
+
+it('protects ordinary declared-property scopes while allowing dynamic literal editors', () => {
+ const root = {
+ definitions: {
+ child: { type: 'object', properties: { 'a.b': { type: 'string' } } },
+ },
+ };
+ expect(
+ mixedHasUnsafeDeclaredScopes({ additionalProperties: true }, root),
+ ).toBe(false);
+ expect(
+ mixedHasUnsafeDeclaredScopes({ $ref: '#/definitions/child' }, root),
+ ).toBe(true);
+ expect(
+ mixedHasUnsafeDeclaredScopes(
+ { additionalProperties: { $ref: '#/definitions/child' } },
+ root,
+ ),
+ ).toBe(true);
+});
diff --git a/packages/vue-vuetify/tests/unit/util/mixedTree.spec.ts b/packages/vue-vuetify/tests/unit/util/mixedTree.spec.ts
new file mode 100644
index 0000000000..a4ec324f9a
--- /dev/null
+++ b/packages/vue-vuetify/tests/unit/util/mixedTree.spec.ts
@@ -0,0 +1,375 @@
+import type { ControlElement } from '@jsonforms/core';
+import type { JsonSchema7 as JsonSchema } from '@jsonforms/core';
+import { describe, expect, it } from 'vitest';
+import {
+ buildTreeFromData,
+ createMixedRenderInfos,
+ findNodeById,
+ flattenTree,
+ getJsonDataType,
+ getSchemaTypesAsArray,
+ schemaSupportsInputType,
+ toTreeNodeId,
+} from '../../../src/util/mixedTree';
+
+describe('mixed tree utilities', () => {
+ const itemLabel = (index: number) => `Item ${index + 1}`;
+
+ describe('deletion permissions', () => {
+ const schema: JsonSchema = {
+ type: 'object',
+ required: ['requiredChild'],
+ properties: {
+ requiredChild: { type: 'object' },
+ list: { $ref: '#/definitions/list' },
+ },
+ definitions: {
+ list: { type: 'array', minItems: 1, items: { type: 'object' } },
+ },
+ };
+ const data = { requiredChild: {}, list: [{}], optional: true };
+
+ it.each([true, false])(
+ 'applies schema restrictions only when restrict is %s',
+ (restrict) => {
+ const nodes = flattenTree(
+ buildTreeFromData(
+ data,
+ schema,
+ schema,
+ '',
+ '',
+ true,
+ false,
+ true,
+ itemLabel,
+ restrict,
+ ),
+ );
+ expect(nodes.find((node) => node.control.path === '')?.canDelete).toBe(
+ false,
+ );
+ expect(
+ nodes.find((node) => node.control.path === 'requiredChild')
+ ?.canDelete,
+ ).toBe(!restrict);
+ expect(
+ nodes.find((node) => node.control.path === 'list.0')?.canDelete,
+ ).toBe(!restrict);
+ expect(
+ nodes.find((node) => node.control.path === 'optional')?.canDelete,
+ ).toBe(true);
+ },
+ );
+
+ it.each([
+ [false, false],
+ [true, true],
+ ])(
+ 'prevents deletion with enabled=%s and readonly=%s',
+ (enabled, readonly) => {
+ const nodes = flattenTree(
+ buildTreeFromData(
+ data,
+ schema,
+ schema,
+ '',
+ '',
+ enabled,
+ readonly,
+ true,
+ itemLabel,
+ ),
+ );
+ expect(nodes.every((node) => !node.canDelete)).toBe(true);
+ },
+ );
+
+ it('protects minProperties and permits deletion above minItems', () => {
+ const root: JsonSchema = {
+ type: 'object',
+ minProperties: 1,
+ properties: {
+ list: { type: 'array', minItems: 1, items: { type: 'number' } },
+ },
+ };
+ const nodes = flattenTree(
+ buildTreeFromData(
+ { list: [1, 2] },
+ root,
+ root,
+ '',
+ '',
+ true,
+ false,
+ true,
+ itemLabel,
+ true,
+ ),
+ );
+ expect(
+ nodes.find((node) => node.control.path === 'list')?.canDelete,
+ ).toBe(false);
+ expect(
+ nodes.find((node) => node.control.path === 'list.0')?.canDelete,
+ ).toBe(true);
+ });
+ });
+
+ describe('JSON type handling', () => {
+ it('distinguishes JSON-compatible values, including integers', () => {
+ expect(getJsonDataType('value')).toBe('string');
+ expect(getJsonDataType(1)).toBe('integer');
+ expect(getJsonDataType(1.5)).toBe('number');
+ expect(getJsonDataType(false)).toBe('boolean');
+ expect(getJsonDataType([])).toBe('array');
+ expect(getJsonDataType({})).toBe('object');
+ expect(getJsonDataType(null)).toBe('null');
+ expect(getJsonDataType(undefined)).toBeNull();
+ });
+
+ it('derives schema types from type arrays and enums', () => {
+ expect(getSchemaTypesAsArray({ type: ['string', 'null'] })).toEqual([
+ 'string',
+ 'null',
+ ]);
+ expect(getSchemaTypesAsArray({ enum: [1, 2.5] })).toEqual([
+ 'integer',
+ 'number',
+ ]);
+ });
+
+ it('allows integers to use a number schema', () => {
+ expect(schemaSupportsInputType('number', 'integer')).toBe(true);
+ expect(schemaSupportsInputType('integer', 'number')).toBe(false);
+ expect(schemaSupportsInputType(['number', 'null'], 'number')).toBe(false);
+ });
+ });
+
+ it('creates cleaned render schemas for each supported mixed type', () => {
+ const schema: JsonSchema = {
+ type: ['string', 'array'],
+ default: 'default value',
+ minLength: 2,
+ minItems: 1,
+ // Boolean items are valid JSON Schema but absent from core's items type.
+ items: true as unknown as JsonSchema,
+ };
+ const originalSchema = structuredClone(schema);
+ const control: ControlElement = {
+ type: 'Control',
+ scope: '#',
+ options: {
+ 'array-detail': { type: 'Control', scope: '#/items' },
+ },
+ };
+
+ const infos = createMixedRenderInfos(
+ schema,
+ schema,
+ schema,
+ control,
+ '',
+ [],
+ );
+
+ expect(infos.map((info) => info.label)).toEqual(['string', 'array']);
+ expect(infos[0].resolvedSchema.default).toBe('default value');
+ expect(infos[0].schema).toMatchObject({
+ type: 'string',
+ minLength: 2,
+ });
+ expect(infos[0].schema.minItems).toBeUndefined();
+ expect(infos[1].resolvedSchema.default).toBeUndefined();
+ expect(infos[1].resolvedSchema.items).toEqual({
+ type: [
+ 'array',
+ 'boolean',
+ 'integer',
+ 'null',
+ 'number',
+ 'object',
+ 'string',
+ ],
+ });
+ expect(infos[1].schema).toMatchObject({ type: 'array', minItems: 1 });
+ expect(infos[1].schema.minLength).toBeUndefined();
+ expect(infos[1].uischema).toMatchObject({
+ type: 'Control',
+ scope: '#/items',
+ });
+ expect(schema).toEqual(originalSchema);
+ });
+
+ it('builds nested object and array nodes with absolute paths', () => {
+ const schema: JsonSchema = {
+ type: 'object',
+ properties: {
+ declared: { type: 'string' },
+ },
+ additionalProperties: true,
+ };
+ const data = {
+ declared: 'value',
+ dynamic: {
+ items: [1, { deep: true }],
+ },
+ };
+
+ const tree = buildTreeFromData(
+ data,
+ schema,
+ schema,
+ 'data',
+ 'Data',
+ true,
+ false,
+ true,
+ itemLabel,
+ );
+
+ expect(flattenTree(tree).map((node) => node.nodeId)).toEqual([
+ '$path:data',
+ '$path:data.declared',
+ '$path:data.dynamic',
+ '$path:data.dynamic.items',
+ '$path:data.dynamic.items.0',
+ '$path:data.dynamic.items.1',
+ '$path:data.dynamic.items.1.deep',
+ ]);
+ expect(findNodeById(tree, '$path:data.declared')).toMatchObject({
+ jsonType: 'string',
+ canRename: false,
+ canDelete: true,
+ });
+ expect(findNodeById(tree, '$path:data.dynamic')).toMatchObject({
+ jsonType: 'object',
+ canRename: true,
+ canDelete: true,
+ });
+ expect(findNodeById(tree, '$path:data.dynamic.items.1.deep')).toMatchObject(
+ {
+ title: 'deep',
+ jsonType: 'boolean',
+ control: {
+ path: 'data.dynamic.items.1.deep',
+ enabled: true,
+ readonly: false,
+ },
+ },
+ );
+ });
+
+ it('hides primitive nodes while retaining complex descendants', () => {
+ const schema: JsonSchema = {
+ type: 'object',
+ additionalProperties: true,
+ };
+ const tree = buildTreeFromData(
+ { primitive: 1, nested: { value: 2 } },
+ schema,
+ schema,
+ '',
+ '',
+ true,
+ false,
+ false,
+ itemLabel,
+ );
+
+ expect(tree[0].nodeId).toBe('$root');
+ expect(tree[0].title).toBe('{}');
+ expect(tree[0].children?.map((node) => node.nodeId)).toEqual([
+ '$path:nested',
+ ]);
+ expect(tree[0].children?.[0].children).toBeUndefined();
+ });
+
+ it('uses tuple and referenced schemas without mutating the input schema', () => {
+ const rootSchema: JsonSchema = {
+ type: 'array',
+ items: [
+ { $ref: '#/definitions/count' },
+ { type: 'object', additionalProperties: false },
+ ],
+ definitions: {
+ count: { type: 'number', minimum: 0 },
+ },
+ };
+ const originalSchema = structuredClone(rootSchema);
+ const tree = buildTreeFromData(
+ [3, {}],
+ rootSchema,
+ rootSchema,
+ 'values',
+ 'Values',
+ false,
+ true,
+ true,
+ itemLabel,
+ );
+
+ expect(findNodeById(tree, '$path:values.0')).toMatchObject({
+ title: 'Item 1',
+ jsonType: 'integer',
+ canRename: false,
+ control: {
+ schema: { type: 'number', minimum: 0, title: 'Item 1' },
+ enabled: false,
+ readonly: true,
+ },
+ });
+ expect(findNodeById(tree, '$path:values.1')?.control.schema).toMatchObject({
+ type: 'object',
+ additionalProperties: false,
+ });
+ expect(rootSchema).toEqual(originalSchema);
+ });
+
+ it('returns no tree for primitive root data and no match for unknown ids', () => {
+ expect(
+ buildTreeFromData(
+ 'value',
+ { type: 'string' },
+ { type: 'string' },
+ 'value',
+ 'Value',
+ true,
+ false,
+ true,
+ itemLabel,
+ ),
+ ).toEqual([]);
+ expect(findNodeById([], toTreeNodeId('missing'))).toBeUndefined();
+ });
+});
+
+it('combines readOnly constraints from all matching property patterns', () => {
+ const schema: JsonSchema = {
+ type: 'object',
+ patternProperties: {
+ '^locked': { type: 'object', readOnly: false },
+ locked$: { allOf: [{ readOnly: true }] },
+ },
+ };
+ const nodes = flattenTree(
+ buildTreeFromData(
+ { locked: { child: 1 }, editable: 2 },
+ schema,
+ schema,
+ '',
+ '',
+ true,
+ false,
+ true,
+ String,
+ ),
+ );
+ const locked = nodes.filter((node) => node.control.path.startsWith('locked'));
+ expect(
+ locked.every(
+ (node) => node.control.readonly && !node.canRename && !node.canDelete,
+ ),
+ ).toBe(true);
+ expect(nodes.find((node) => node.label === 'editable')?.canDelete).toBe(true);
+});