diff --git a/packages/vue-vuetify/README.md b/packages/vue-vuetify/README.md
index e782a711d7..eee67687e3 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:
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..b4a33b6659 100644
--- a/packages/vue-vuetify/package.json
+++ b/packages/vue-vuetify/package.json
@@ -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..e0d2e13a7d 100644
--- a/packages/vue-vuetify/src/complex/MixedRenderer.vue
+++ b/packages/vue-vuetify/src/complex/MixedRenderer.vue
@@ -1,13 +1,13 @@
-
-
-
+
-
+
-
+ />
+
+
+
+ open ? startRename(element.propertyName) : cancelRename()
+ "
+ >
+
+
+ {{
+ icons.current.value.itemEdit
+ }}
+
+
+
+
{{
@@ -83,9 +156,9 @@
{{ translations.removeTooltip }}
-
-
+
+
+
@@ -93,11 +166,12 @@
+
+
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..4c4da9297c 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,50 @@ export const getAdditionalPropertiesTranslations = (
});
return translations;
};
+
+export interface MixedRendererTranslations {
+ 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 {
+ 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..d0e3a3855a
--- /dev/null
+++ b/packages/vue-vuetify/src/i18n/mixedRendererTranslations.ts
@@ -0,0 +1,63 @@
+export interface MixedRendererDefaultTranslation {
+ key: MixedRendererTranslationEnum;
+ default: (variable?: string | number | null) => string;
+}
+
+export enum MixedRendererTranslationEnum {
+ 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.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/composition.ts b/packages/vue-vuetify/src/util/composition.ts
index e50e799ad7..0be5533be9 100644
--- a/packages/vue-vuetify/src/util/composition.ts
+++ b/packages/vue-vuetify/src/util/composition.ts
@@ -507,7 +507,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..4554594d0b
--- /dev/null
+++ b/packages/vue-vuetify/src/util/dynamicProperties.ts
@@ -0,0 +1,196 @@
+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;
+ currentPropertyName?: string;
+ data: unknown;
+ 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;
+ }
+
+ const pattern = Object.keys(parentSchema.patternProperties ?? {}).find(
+ (candidate) => new RegExp(candidate).test(propertyName),
+ );
+ if (pattern) {
+ const patternSchema = parentSchema.patternProperties?.[pattern];
+ return typeof patternSchema?.$ref === 'string'
+ ? (Resolve.schema(rootSchema, patternSchema.$ref, rootSchema) ??
+ patternSchema)
+ : patternSchema;
+ }
+
+ const additionalProperties = parentSchema.additionalProperties;
+ if (typeof additionalProperties === 'object') {
+ return typeof additionalProperties.$ref === 'string'
+ ? (Resolve.schema(rootSchema, additionalProperties.$ref, rootSchema) ??
+ additionalProperties)
+ : additionalProperties;
+ }
+
+ return additionalProperties === true
+ ? { additionalProperties: true }
+ : undefined;
+};
+
+export const getPropertyNameSchema = (
+ schema: JsonSchema,
+ rootSchema: JsonSchema,
+): JsonSchema7 => {
+ const result: JsonSchema7 = { type: 'string' };
+ const propertyNames = (schema as JsonSchema & { propertyNames?: JsonSchema })
+ .propertyNames;
+
+ if (propertyNames && typeof propertyNames === 'object') {
+ const resolved =
+ typeof propertyNames.$ref === 'string'
+ ? (Resolve.schema(rootSchema, propertyNames.$ref, rootSchema) ??
+ propertyNames)
+ : propertyNames;
+ return { ...resolved, ...result } as JsonSchema7;
+ }
+
+ if (
+ schema.additionalProperties === false &&
+ schema.patternProperties &&
+ typeof schema.patternProperties === 'object'
+ ) {
+ const patterns = Object.keys(schema.patternProperties);
+ if (patterns.length > 0) {
+ return { pattern: patterns.join('|'), ...result };
+ }
+ }
+
+ return result;
+};
+
+export const validateDynamicPropertyName = ({
+ propertyName,
+ currentPropertyName,
+ data,
+ propertyNameSchema,
+ ajv,
+}: DynamicPropertyNameValidationOptions): DynamicPropertyNameValidationError | null => {
+ if (!propertyName) {
+ return null;
+ }
+
+ 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 (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/mixedTree.ts b/packages/vue-vuetify/src/util/mixedTree.ts
new file mode 100644
index 0000000000..2256b1419b
--- /dev/null
+++ b/packages/vue-vuetify/src/util/mixedTree.ts
@@ -0,0 +1,576 @@
+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 {
+ composePropertyPath,
+ findPropertySchema,
+} 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;
+ 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;
+};
+
+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 = findPropertySchema(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];
+ }
+
+ if (childType === 'object') {
+ return prepareObjectSchema(childSchema);
+ }
+ if (childType === 'array') {
+ return prepareArraySchema(childSchema, rootSchema);
+ }
+ 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' ? '[]' : '{}');
+
+const isDynamicProperty = (parentSchema: JsonSchema, key: string): boolean =>
+ !parentSchema.properties?.[key];
+
+export const buildTreeFromData = (
+ data: any,
+ schema: JsonSchema,
+ rootSchema: JsonSchema,
+ path: string,
+ label: string,
+ enabled: boolean,
+ readonly: boolean,
+ showPrimitives: boolean,
+ itemLabel: (index: number) => string,
+): MixedTreeNode[] => {
+ const dataType = getJsonDataType(data);
+ if (dataType !== 'object' && dataType !== 'array') {
+ return [];
+ }
+
+ const nodes: MixedTreeNode[] = [];
+
+ const traverse = (
+ value: any,
+ currentPath: string,
+ currentLabel: string,
+ currentSchema: JsonSchema,
+ children: MixedTreeNode[],
+ canRename = false,
+ canDelete = false,
+ ): void => {
+ const type = getJsonDataType(value);
+
+ if (type === 'object') {
+ const objectSchema = prepareObjectSchema(currentSchema);
+ const node: MixedTreeNode = {
+ nodeId: toTreeNodeId(currentPath),
+ title: getDisplayTitle(currentLabel, type),
+ jsonType: type,
+ label: currentLabel,
+ canRename,
+ canDelete,
+ control: createTreeNodeControl(
+ objectSchema,
+ currentPath,
+ enabled,
+ readonly,
+ ),
+ children: [],
+ };
+ children.push(node);
+
+ Object.keys(value).forEach((key) => {
+ const childValue = value[key];
+ const childPath = composePropertyPath(currentPath, key);
+ const rawChildType = getJsonDataType(childValue);
+ const initialChildSchema =
+ findPropertySchema(currentSchema, key, rootSchema) ?? {
+ type: [...JSON_TYPES],
+ title: key,
+ };
+ const childType =
+ rawChildType ?? getSchemaDefaultType(initialChildSchema);
+ const childSchema = prepareChildSchema(
+ childType,
+ currentSchema,
+ key,
+ null,
+ rootSchema,
+ );
+ const childCanRename = isDynamicProperty(currentSchema, key);
+
+ if (childType === 'object' || childType === 'array') {
+ traverse(
+ childValue ?? (childType === 'array' ? [] : {}),
+ childPath,
+ key,
+ childSchema,
+ node.children!,
+ childCanRename,
+ true,
+ );
+ } else if (showPrimitives) {
+ node.children!.push({
+ nodeId: toTreeNodeId(childPath),
+ title: key,
+ jsonType: childType,
+ label: key,
+ canRename: childCanRename,
+ canDelete: true,
+ control: createTreeNodeControl(
+ childSchema,
+ childPath,
+ enabled,
+ readonly,
+ ),
+ });
+ }
+ });
+ } 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,
+ control: createTreeNodeControl(
+ arraySchema,
+ currentPath,
+ enabled,
+ readonly,
+ ),
+ 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 resolvedChildType =
+ childType ?? getSchemaDefaultType(childSchema);
+
+ if (resolvedChildType === 'object' || resolvedChildType === 'array') {
+ traverse(
+ childValue ?? (resolvedChildType === 'array' ? [] : {}),
+ childPath,
+ childLabel,
+ childSchema,
+ node.children!,
+ false,
+ true,
+ );
+ } else if (showPrimitives) {
+ node.children!.push({
+ nodeId: toTreeNodeId(childPath),
+ title: childLabel,
+ jsonType: resolvedChildType,
+ label: childLabel,
+ canRename: false,
+ canDelete: true,
+ control: createTreeNodeControl(
+ childSchema,
+ childPath,
+ enabled,
+ readonly,
+ ),
+ });
+ }
+ });
+ }
+ };
+
+ 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..845d49f337
--- /dev/null
+++ b/packages/vue-vuetify/tests/unit/additional/AdditionalPropertiesMixed.spec.ts
@@ -0,0 +1,33 @@
+import { clearAllIds } from '@jsonforms/core';
+import { nextTick } from 'vue';
+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();
+ });
+
+ 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);
+ });
+});
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..676ffa22af
--- /dev/null
+++ b/packages/vue-vuetify/tests/unit/util/dynamicProperties.spec.ts
@@ -0,0 +1,214 @@
+import { createAjv, type JsonSchema } from '@jsonforms/core';
+import { describe, expect, it } from 'vitest';
+import {
+ composePropertyPath,
+ findPropertySchema,
+ getDynamicPropertyNameErrorMessage,
+ getPathAncestorPaths,
+ getPropertyNameSchema,
+ haveAdditionalPropertyNamesChanged,
+ validateDynamicPropertyName,
+} from '../../../src/util/dynamicProperties';
+
+describe('dynamic property utilities', () => {
+ const rootSchema: JsonSchema = {
+ type: 'object',
+ $defs: {
+ propertyName: {
+ type: 'string',
+ pattern: '^[A-Z][A-Za-z0-9]*$',
+ },
+ patternValue: {
+ type: 'number',
+ },
+ },
+ };
+
+ 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: '#/$defs/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: '#/$defs/propertyName' },
+ } as JsonSchema;
+
+ expect(getPropertyNameSchema(schema, rootSchema)).toEqual({
+ 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', pattern: '^allowed' });
+
+ expect(
+ getPropertyNameSchema(
+ {
+ type: 'object',
+ patternProperties,
+ additionalProperties: true,
+ },
+ rootSchema,
+ ),
+ ).toEqual({ type: 'string' });
+ });
+ });
+
+ describe('validateDynamicPropertyName', () => {
+ 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: '#/$defs/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);
+ });
+ });
+});
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/mixedTree.spec.ts b/packages/vue-vuetify/tests/unit/util/mixedTree.spec.ts
new file mode 100644
index 0000000000..7ebad92469
--- /dev/null
+++ b/packages/vue-vuetify/tests/unit/util/mixedTree.spec.ts
@@ -0,0 +1,236 @@
+import type { ControlElement, 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('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,
+ items: true,
+ };
+ 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: '#/$defs/count' },
+ { type: 'object', additionalProperties: false },
+ ],
+ $defs: {
+ 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();
+ });
+});