From 4c4e84f3c62faeb7127562eb41edc3ea6111c9e4 Mon Sep 17 00:00:00 2001 From: Krasimir Chobantonov Date: Sun, 2 Aug 2026 05:56:34 +0300 Subject: [PATCH 01/13] feat(vue-vuetify): improve mixed and additional property editors --- .../vue-vuetify/dev/views/ExampleView.vue | 43 +- packages/vue-vuetify/package.json | 1 + .../vue-vuetify/src/complex/MixedRenderer.vue | 1385 +++++++++++++++-- .../components/AdditionalProperties.vue | 500 ++++-- packages/vue-vuetify/src/components/VPane.vue | 18 + .../src/components/VSplitpanes.sass | 218 +++ .../src/components/VSplitpanes.vue | 27 + packages/vue-vuetify/src/components/index.ts | 2 + packages/vue-vuetify/src/icons/fa.ts | 17 +- packages/vue-vuetify/src/icons/icons.ts | 15 + packages/vue-vuetify/src/icons/mdi.ts | 17 +- packages/vue-vuetify/src/util/composition.ts | 2 +- .../vue-vuetify/src/util/dynamicProperties.ts | 166 ++ .../AdditionalPropertiesMixed.spec.ts | 33 + .../tests/unit/util/dynamicProperties.spec.ts | 181 +++ 15 files changed, 2252 insertions(+), 373 deletions(-) create mode 100644 packages/vue-vuetify/src/components/VPane.vue create mode 100644 packages/vue-vuetify/src/components/VSplitpanes.sass create mode 100644 packages/vue-vuetify/src/components/VSplitpanes.vue create mode 100644 packages/vue-vuetify/src/components/index.ts create mode 100644 packages/vue-vuetify/src/util/dynamicProperties.ts create mode 100644 packages/vue-vuetify/tests/unit/additional/AdditionalPropertiesMixed.spec.ts create mode 100644 packages/vue-vuetify/tests/unit/util/dynamicProperties.spec.ts 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) => {
- - + @@ -348,8 +346,8 @@ const handleAction = (action: Action) => { - - + + @@ -389,8 +387,8 @@ const handleAction = (action: Action) => { :editorBeforeMount="registerValidations" > - - + +
@@ -509,28 +507,3 @@ const handleAction = (action: Action) => { - diff --git a/packages/vue-vuetify/package.json b/packages/vue-vuetify/package.json index 7c92412a2f..a26fc50f1a 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..4888a06fe6 100644 --- a/packages/vue-vuetify/src/complex/MixedRenderer.vue +++ b/packages/vue-vuetify/src/complex/MixedRenderer.vue @@ -1,13 +1,13 @@ - Rename + {{ mixedTranslations.renameTooltip }} - Delete + {{ mixedTranslations.deleteTooltip }} @@ -209,13 +224,14 @@ v-bind="props" class="mixed-navigate-button" :icon="icons.current.value.visibilityOn" + :aria-label="mixedTranslations.viewAriaLabel(computedLabel)" variant="text" color="primary" :disabled="!navigationContext" @click="selectCurrentPath" /> - View {{ computedLabel }} + {{ mixedTranslations.viewTooltip(computedLabel) }} @@ -262,7 +278,10 @@ diff --git a/packages/vue-vuetify/src/util/dynamicProperties.ts b/packages/vue-vuetify/src/util/dynamicProperties.ts index ccf5c34b1c..bf389a7e06 100644 --- a/packages/vue-vuetify/src/util/dynamicProperties.ts +++ b/packages/vue-vuetify/src/util/dynamicProperties.ts @@ -11,6 +11,7 @@ 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. */ @@ -174,15 +175,16 @@ export const validateDynamicPropertyName = ({ data, reservedPropertyNames = [], allowDots = false, + allowEmptyPropertyNames = false, propertyNameSchema, ajv, }: DynamicPropertyNameValidationOptions): DynamicPropertyNameValidationError | null => { - if (reservedPropertyNames.includes(propertyName)) { - return { reason: 'alreadyDefined' }; + if (!allowEmptyPropertyNames && propertyName.trim().length === 0) { + return { reason: 'invalid' }; } - if (!propertyName) { - return null; + if (reservedPropertyNames.includes(propertyName)) { + return { reason: 'alreadyDefined' }; } if ( diff --git a/packages/vue-vuetify/src/util/mixedLiteral.ts b/packages/vue-vuetify/src/util/mixedLiteral.ts new file mode 100644 index 0000000000..d771283bf7 --- /dev/null +++ b/packages/vue-vuetify/src/util/mixedLiteral.ts @@ -0,0 +1,88 @@ +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 === '' || /[.\u0000]/.test(key) + ? '\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 === '' || + /[.\u0000]/.test(key) || + 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 index 8e2b0d9775..a94f4bb0f0 100644 --- a/packages/vue-vuetify/src/util/mixedTree.ts +++ b/packages/vue-vuetify/src/util/mixedTree.ts @@ -1,3 +1,7 @@ +import { + encodeMixedSegment, + mixedHasUnsafeDeclaredScopes, +} from './mixedLiteral'; import { Resolve, createControlElement, @@ -56,8 +60,6 @@ export interface MixedTreeNode { control: TreeNodeControl; /** Original allowed types for the selected node editor; control.schema is the tree view. */ editorSchema: JsonSchema; - /** Literal display for keys that core cannot address with a data path. */ - uneditableValue?: string; children?: MixedTreeNode[]; } @@ -539,7 +541,8 @@ export const buildTreeFromData = ( objectSchema, currentPath, enabled, - nodeReadonly, + nodeReadonly || + mixedHasUnsafeDeclaredScopes(currentSchema, rootSchema), ), children: [], }; @@ -547,24 +550,10 @@ export const buildTreeFromData = ( Object.keys(value).forEach((key) => { const childValue = value[key]; - // Dots (and empty keys) cannot be represented by core's data paths. - // Keep a distinct, view-only node instead of aliasing another property. - if (key.includes('.') || key === '') { - node.children!.push({ - nodeId: `$literal:${JSON.stringify([currentPath, key])}`, - title: key, - label: key, - jsonType: getJsonDataType(childValue) ?? 'null', - canRename: false, - canDelete: false, - editorSchema: {}, - control: createTreeNodeControl({}, currentPath, false, true), - uneditableValue: - JSON.stringify(childValue, null, 2) ?? String(childValue), - }); - return; - } - const childPath = composePropertyPath(currentPath, key); + const childPath = composePropertyPath( + currentPath, + encodeMixedSegment(key), + ); const rawChildType = getJsonDataType(childValue); const initialChildSchema = findTreePropertySchema( currentSchema, @@ -605,7 +594,7 @@ export const buildTreeFromData = ( traverse( childValue ?? (childType === 'array' ? [] : {}), childPath, - key, + key === '' ? '""' : key, childSchema, node.children!, childCanRename, @@ -615,9 +604,9 @@ export const buildTreeFromData = ( } else if (showPrimitives) { node.children!.push({ nodeId: toTreeNodeId(childPath), - title: key, + title: key === '' ? '""' : key, jsonType: childType, - label: key, + label: key === '' ? '""' : key, editorSchema: childSchema, canRename: childCanRename, canDelete: childCanDelete, @@ -646,7 +635,8 @@ export const buildTreeFromData = ( arraySchema, currentPath, enabled, - nodeReadonly, + nodeReadonly || + mixedHasUnsafeDeclaredScopes(currentSchema, rootSchema), ), children: [], }; diff --git a/packages/vue-vuetify/tests/unit/additional/StructuralEditing.spec.ts b/packages/vue-vuetify/tests/unit/additional/StructuralEditing.spec.ts index c3c44ba3d5..12d655506e 100644 --- a/packages/vue-vuetify/tests/unit/additional/StructuralEditing.spec.ts +++ b/packages/vue-vuetify/tests/unit/additional/StructuralEditing.spec.ts @@ -770,12 +770,9 @@ describe('MixedRenderer safe tree mutations', () => { const literal = nodes.find((node) => node.label === 'a.b')!; vm.deleteNode(literal); await nextTick(); - expect(wrapper.vm.event.data).toEqual(data); + 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(false); - vm.activatedTreeNodes = [literal.nodeId]; - await nextTick(); - expect(wrapper.find('.mixed-detail-pane pre').text()).toBe('1'); + expect(literal.canRename).toBe(true); }); it.each(['object', 'array'] as const)( @@ -956,3 +953,309 @@ it('ignores literal-value changes when the parent is read-only', async () => { 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(); +}); diff --git a/packages/vue-vuetify/tests/unit/util/dynamicProperties.spec.ts b/packages/vue-vuetify/tests/unit/util/dynamicProperties.spec.ts index 75bf092129..692beb5084 100644 --- a/packages/vue-vuetify/tests/unit/util/dynamicProperties.spec.ts +++ b/packages/vue-vuetify/tests/unit/util/dynamicProperties.spec.ts @@ -382,3 +382,33 @@ describe('dynamic property utilities', () => { }); }); }); + +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/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); +}); From c647ed4cdf9e41ac89b779809764a1f0cf05079d Mon Sep 17 00:00:00 2001 From: Krasimir Chobantonov Date: Mon, 21 Sep 2026 01:15:27 -0400 Subject: [PATCH 11/13] fix(vue-vuetify): combine all matching dynamic property schemas Merge compatible scalar constraints from matching patternProperties and preserve complex or conflicting schemas with allOf. Keep combinator renderer selection intact and cache compiled patterns. Add utility and component regression tests for overlapping patterns. --- .../components/AdditionalProperties.vue | 7 +- .../src/util/additionalPropertySchema.ts | 131 ++++++++++++++++++ .../vue-vuetify/src/util/dynamicProperties.ts | 24 +--- .../unit/additional/StructuralEditing.spec.ts | 47 +++++++ .../util/additionalPropertySchema.spec.ts | 83 +++++++++++ 5 files changed, 269 insertions(+), 23 deletions(-) create mode 100644 packages/vue-vuetify/src/util/additionalPropertySchema.ts create mode 100644 packages/vue-vuetify/tests/unit/util/additionalPropertySchema.spec.ts diff --git a/packages/vue-vuetify/src/complex/components/AdditionalProperties.vue b/packages/vue-vuetify/src/complex/components/AdditionalProperties.vue index 3950ba3b18..b52c5f358c 100644 --- a/packages/vue-vuetify/src/complex/components/AdditionalProperties.vue +++ b/packages/vue-vuetify/src/complex/components/AdditionalProperties.vue @@ -308,7 +308,12 @@ export default defineComponent({ propSchema = propSchema ?? {}; - if (propSchema.type === undefined) { + if ( + propSchema.type === undefined && + !propSchema.allOf && + !propSchema.anyOf && + !propSchema.oneOf + ) { propSchema = { ...propSchema, type: [ 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/dynamicProperties.ts b/packages/vue-vuetify/src/util/dynamicProperties.ts index bf389a7e06..445949fda1 100644 --- a/packages/vue-vuetify/src/util/dynamicProperties.ts +++ b/packages/vue-vuetify/src/util/dynamicProperties.ts @@ -1,3 +1,4 @@ +import { additionalPropertySchema } from './additionalPropertySchema'; import { Resolve, type JsonSchema, type JsonSchema7 } from '@jsonforms/core'; import type Ajv from 'ajv'; import type { ErrorObject } from 'ajv'; @@ -81,28 +82,7 @@ export const findPropertySchema = ( : 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; + return additionalPropertySchema(propertyName, parentSchema, rootSchema); }; /** Rename preserves property count, so only name ownership and editability matter. */ diff --git a/packages/vue-vuetify/tests/unit/additional/StructuralEditing.spec.ts b/packages/vue-vuetify/tests/unit/additional/StructuralEditing.spec.ts index 12d655506e..9ee929b9a8 100644 --- a/packages/vue-vuetify/tests/unit/additional/StructuralEditing.spec.ts +++ b/packages/vue-vuetify/tests/unit/additional/StructuralEditing.spec.ts @@ -1259,3 +1259,50 @@ it('keeps an untouched or reset property-name field free of errors while guardin 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', + }); + }); +}); From 003526ce4bd9215bd7bc4ed033b1912eadd17735 Mon Sep 17 00:00:00 2001 From: Krasimir Chobantonov Date: Mon, 21 Sep 2026 02:24:46 -0400 Subject: [PATCH 12/13] fix lint error --- packages/vue-vuetify/src/util/mixedLiteral.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/vue-vuetify/src/util/mixedLiteral.ts b/packages/vue-vuetify/src/util/mixedLiteral.ts index d771283bf7..42b75602b4 100644 --- a/packages/vue-vuetify/src/util/mixedLiteral.ts +++ b/packages/vue-vuetify/src/util/mixedLiteral.ts @@ -1,7 +1,7 @@ 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 === '' || /[.\u0000]/.test(key) + key === '' || key.includes('.') || key.includes('\0') ? '\0' + key .split('') @@ -65,7 +65,8 @@ export function mixedHasUnsafeDeclaredScopes( for (const [key, child] of Object.entries(schema.properties ?? {})) { if ( key === '' || - /[.\u0000]/.test(key) || + key.includes('.') || + key.includes('\0') || mixedHasUnsafeDeclaredScopes(child, root, seen) ) return true; From 3f7812468ec229a8bbb6890fd26804ed2214c223 Mon Sep 17 00:00:00 2001 From: Krasimir Chobantonov Date: Mon, 21 Sep 2026 21:24:10 -0400 Subject: [PATCH 13/13] perf(vue-vuetify): reduce mixed renderer tree rebuilding --- .../vue-vuetify/src/complex/MixedRenderer.vue | 36 +++-- .../additional/MixedTreeReactivity.spec.ts | 136 ++++++++++++++++++ 2 files changed, 160 insertions(+), 12 deletions(-) create mode 100644 packages/vue-vuetify/tests/unit/additional/MixedTreeReactivity.spec.ts diff --git a/packages/vue-vuetify/src/complex/MixedRenderer.vue b/packages/vue-vuetify/src/complex/MixedRenderer.vue index 8e86fd3778..f81347df80 100644 --- a/packages/vue-vuetify/src/complex/MixedRenderer.vue +++ b/packages/vue-vuetify/src/complex/MixedRenderer.vue @@ -535,18 +535,30 @@ const controlRenderer = defineComponent({ renameValue.value, ); + // The control binding produces a new object for any form-state update. + // Project stable inputs so unrelated edits do not rebuild schemas or trees. + const treeData = computed(() => input.control.value.data); + const controlSchema = computed(() => input.control.value.schema); + const rootSchema = computed(() => input.control.value.rootSchema); + const controlUISchema = computed(() => input.control.value.uischema); + const controlPath = computed(() => input.control.value.path); + const treeEnabled = computed(() => input.control.value.enabled); + const treeReadonly = computed(() => input.control.value.readonly); + const treeRestrict = computed( + () => !!vuetifyControl.appliedOptions.value.restrict, + ); + const mixedRenderInfos = computed< (SchemaRenderInfo & { index: number; })[] >(() => { - const control = input.control.value; const result = createMixedRenderInfos( props.schema, - control.schema, - control.rootSchema, - control.uischema, - control.path, + controlSchema.value, + rootSchema.value, + controlUISchema.value, + controlPath.value, jsonforms.uischemas || [], ); @@ -634,16 +646,16 @@ const controlRenderer = defineComponent({ const allTreeNodes = computed(() => showTreeView.value ? buildTreeFromData( - input.control.value.data, - resolvedSchema.value ?? input.control.value.schema, - input.control.value.rootSchema, - input.control.value.path, + treeData.value, + resolvedSchema.value ?? controlSchema.value, + rootSchema.value, + controlPath.value, vuetifyControl.computedLabel.value, - input.control.value.enabled, - input.control.value.readonly, + treeEnabled.value, + treeReadonly.value, true, mixedTranslations.itemLabel, - !!vuetifyControl.appliedOptions.value.restrict, + treeRestrict.value, ) : [], ); 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')]); +});