From 787b47cb9731ea582de915bf88b3dd2aac6f70dc Mon Sep 17 00:00:00 2001 From: Krasimir Chobantonov Date: Wed, 17 Jun 2026 02:01:12 +0300 Subject: [PATCH 1/2] feat(renderers): add mixed and additional property editors --- .../angular-material/src/library/index.ts | 2 + .../angular-material/src/library/module.ts | 8 + .../other/additional-properties.renderer.ts | 698 +++++++ .../src/library/other/index.ts | 2 + .../src/library/other/mixed.renderer.ts | 1678 ++++++++++++++++ .../src/library/other/object.renderer.ts | 37 +- .../angular/src/library/abstract-control.ts | 10 +- .../src/library/jsonforms.component.ts | 23 +- packages/material-renderers/package.json | 2 + .../src/complex/DynamicPropertyDispatch.tsx | 111 + .../complex/MaterialAdditionalProperties.tsx | 609 ++++++ .../src/complex/MaterialMixedRenderer.tsx | 1777 +++++++++++++++++ .../src/complex/MaterialObjectRenderer.tsx | 109 +- .../material-renderers/src/complex/index.ts | 7 + .../src/complex/unwrapped.ts | 2 + packages/material-renderers/src/index.ts | 3 + .../vue-vuetify/dev/views/ExampleView.vue | 43 +- packages/vue-vuetify/package.json | 3 +- .../vue-vuetify/src/complex/MixedRenderer.vue | 1401 +++++++++++-- .../src/complex/ObjectRenderer.vue | 64 +- .../components/AdditionalProperties.vue | 338 +++- 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 | 15 + packages/vue-vuetify/src/icons/icons.ts | 15 + packages/vue-vuetify/src/icons/mdi.ts | 15 + packages/vue-vuetify/src/index.ts | 1 + pnpm-lock.yaml | 188 +- 30 files changed, 7132 insertions(+), 294 deletions(-) create mode 100644 packages/angular-material/src/library/other/additional-properties.renderer.ts create mode 100644 packages/angular-material/src/library/other/mixed.renderer.ts create mode 100644 packages/material-renderers/src/complex/DynamicPropertyDispatch.tsx create mode 100644 packages/material-renderers/src/complex/MaterialAdditionalProperties.tsx create mode 100644 packages/material-renderers/src/complex/MaterialMixedRenderer.tsx 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 diff --git a/packages/angular-material/src/library/index.ts b/packages/angular-material/src/library/index.ts index 0035f8840d..f3abd62621 100644 --- a/packages/angular-material/src/library/index.ts +++ b/packages/angular-material/src/library/index.ts @@ -62,6 +62,7 @@ import { ObjectControlRenderer, ObjectControlRendererTester, } from './other/object.renderer'; +import { MixedRenderer, MixedRendererTester } from './other/mixed.renderer'; import { VerticalLayoutRenderer, verticalLayoutTester, @@ -109,6 +110,7 @@ export const angularMaterialRenderers: { { tester: ToggleControlRendererTester, renderer: ToggleControlRenderer }, { tester: enumControlTester, renderer: EnumControlRenderer }, { tester: oneOfEnumControlTester, renderer: OneOfEnumControlRenderer }, + { tester: MixedRendererTester, renderer: MixedRenderer }, { tester: ObjectControlRendererTester, renderer: ObjectControlRenderer }, // layouts { tester: verticalLayoutTester, renderer: VerticalLayoutRenderer }, diff --git a/packages/angular-material/src/library/module.ts b/packages/angular-material/src/library/module.ts index f286658e1d..9748cf5125 100644 --- a/packages/angular-material/src/library/module.ts +++ b/packages/angular-material/src/library/module.ts @@ -43,6 +43,7 @@ import { MatSlideToggleModule } from '@angular/material/slide-toggle'; import { MatTableModule } from '@angular/material/table'; import { MatTabsModule } from '@angular/material/tabs'; import { MatToolbarModule } from '@angular/material/toolbar'; +import { MatTreeModule } from '@angular/material/tree'; import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core'; import { JsonFormsModule } from '@jsonforms/angular'; import { BooleanControlRenderer } from './controls/boolean.renderer'; @@ -57,6 +58,8 @@ import { TextAreaRenderer } from './controls/textarea.renderer'; import { TextControlRenderer } from './controls/text.renderer'; import { ToggleControlRenderer } from './controls/toggle.renderer'; import { LabelRenderer } from './other/label.renderer'; +import { AdditionalPropertiesRenderer } from './other/additional-properties.renderer'; +import { MixedRenderer } from './other/mixed.renderer'; import { JsonFormsDetailComponent } from './other/master-detail/detail'; import { MasterListComponent } from './other/master-detail/master'; import { ObjectControlRenderer } from './other/object.renderer'; @@ -92,6 +95,7 @@ import { LayoutChildrenRenderPropsPipe } from './layouts'; MatToolbarModule, MatTooltipModule, MatBadgeModule, + MatTreeModule, BooleanControlRenderer, TextAreaRenderer, TextControlRenderer, @@ -106,6 +110,8 @@ import { LayoutChildrenRenderPropsPipe } from './layouts'; LabelRenderer, MasterListComponent, JsonFormsDetailComponent, + AdditionalPropertiesRenderer, + MixedRenderer, ObjectControlRenderer, EnumControlRenderer, OneOfEnumControlRenderer, @@ -147,6 +153,8 @@ import { LayoutChildrenRenderPropsPipe } from './layouts'; LabelRenderer, MasterListComponent, JsonFormsDetailComponent, + AdditionalPropertiesRenderer, + MixedRenderer, ObjectControlRenderer, EnumControlRenderer, OneOfEnumControlRenderer, diff --git a/packages/angular-material/src/library/other/additional-properties.renderer.ts b/packages/angular-material/src/library/other/additional-properties.renderer.ts new file mode 100644 index 0000000000..2bc948a302 --- /dev/null +++ b/packages/angular-material/src/library/other/additional-properties.renderer.ts @@ -0,0 +1,698 @@ +/* + The MIT License + + Copyright (c) 2017-2026 EclipseSource Munich + https://github.com/eclipsesource/jsonforms + + Permission is hereby granted, free of charge, to any person obtaining a copy + of this software and associated documentation files (the "Software"), to deal + in the Software without restriction, including without limitation the rights + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + copies of the Software, and to permit persons to whom the Software is + furnished to do so, subject to the following conditions: + + The above copyright notice and this permission notice shall be included in + all copies or substantial portions of the Software. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + THE SOFTWARE. +*/ +import { CommonModule } from '@angular/common'; +import { + ChangeDetectionStrategy, + Component, + ViewEncapsulation, + Input, + OnChanges, + SimpleChanges, + inject, +} from '@angular/core'; +import { FormsModule } from '@angular/forms'; +import { MatButtonModule } from '@angular/material/button'; +import { MatFormFieldModule } from '@angular/material/form-field'; +import { MatIconModule } from '@angular/material/icon'; +import { MatInputModule } from '@angular/material/input'; +import { MatMenuModule } from '@angular/material/menu'; +import { MatTooltipModule } from '@angular/material/tooltip'; +import { JsonFormsModule, JsonFormsAngularService } from '@jsonforms/angular'; +import { + Actions, + composePaths, + ControlElement, + createControlElement, + createDefaultValue, + Generate, + GroupLayout, + JsonFormsCellRendererRegistryEntry, + JsonFormsRendererRegistryEntry, + JsonSchema, + JsonSchema7, + Resolve, + UISchemaElement, +} from '@jsonforms/core'; +import startCase from 'lodash/startCase'; + +interface AdditionalPropertyItem { + propertyName: string; + path: string; + schema: JsonSchema; + uischema: UISchemaElement; +} + +const ANY_TYPE: JsonSchema7['type'] = [ + 'array', + 'boolean', + 'integer', + 'null', + 'number', + 'object', + 'string', +]; + +const toObjectSchema = (schema: JsonSchema): JsonSchema7 => + typeof schema === 'object' ? (schema as JsonSchema7) : {}; + +const hasAdditionalProperties = (schema: JsonSchema): boolean => { + const objectSchema = toObjectSchema(schema); + return ( + Boolean( + objectSchema.patternProperties && + Object.keys(objectSchema.patternProperties).length > 0 + ) || + typeof objectSchema.additionalProperties === 'object' || + objectSchema.additionalProperties === true + ); +}; + +const getMatchingAdditionalPropertySchema = ( + propName: string, + parentSchema: JsonSchema, + rootSchema: JsonSchema +): JsonSchema => { + const objectSchema = toObjectSchema(parentSchema); + let propSchema: JsonSchema | undefined; + + if (objectSchema.patternProperties) { + const matchedPattern = Object.keys(objectSchema.patternProperties).find( + (pattern) => new RegExp(pattern).test(propName) + ); + if (matchedPattern) { + propSchema = objectSchema.patternProperties[matchedPattern]; + } + } + + if ( + (!propSchema && typeof objectSchema.additionalProperties === 'object') || + objectSchema.additionalProperties === true + ) { + propSchema = + objectSchema.additionalProperties === true + ? { additionalProperties: true } + : objectSchema.additionalProperties; + } + + if (typeof propSchema === 'object' && typeof propSchema.$ref === 'string') { + propSchema = Resolve.schema(rootSchema, propSchema.$ref, rootSchema); + } + + propSchema = propSchema ?? {}; + + if (typeof propSchema === 'object' && propSchema.type === undefined) { + propSchema = { + ...propSchema, + type: ANY_TYPE, + }; + } + + return propSchema; +}; + +const toAdditionalPropertyItem = ( + propName: string, + parentPath: string, + parentSchema: JsonSchema, + rootSchema: JsonSchema +): AdditionalPropertyItem => { + let propSchema = getMatchingAdditionalPropertySchema( + propName, + parentSchema, + rootSchema + ); + let propUiSchema: UISchemaElement = createControlElement('#'); + + if (typeof propSchema === 'object' && propSchema.type === 'array') { + propUiSchema = Generate.uiSchema( + propSchema, + 'Group', + undefined, + rootSchema + ); + (propUiSchema as GroupLayout).label = + propSchema.title ?? startCase(propName); + } + + if (typeof propSchema === 'object') { + propSchema = { + ...propSchema, + title: propName, + }; + if (propSchema.type === 'object') { + propSchema.additionalProperties = + propSchema.additionalProperties !== false + ? propSchema.additionalProperties ?? true + : false; + } else if (propSchema.type === 'array') { + propSchema.items = propSchema.items ?? {}; + } + } + + return { + propertyName: propName, + path: composePaths(parentPath, propName), + schema: propSchema, + uischema: propUiSchema, + }; +}; + +const getPropertyNamePattern = (schema: JsonSchema): string | undefined => { + const objectSchema = toObjectSchema(schema); + const propertyNames = objectSchema.propertyNames as JsonSchema7 | undefined; + if (typeof propertyNames === 'object' && propertyNames.pattern) { + return propertyNames.pattern; + } + + if ( + objectSchema.additionalProperties === false && + objectSchema.patternProperties + ) { + const patterns = Object.keys(objectSchema.patternProperties); + return patterns.length > 0 ? patterns.join('|') : undefined; + } + + return undefined; +}; + +@Component({ + selector: 'AdditionalPropertiesRenderer', + template: ` +
+
+ + {{ additionalPropertiesTitle }} + + + Property Name + + + {{ + propertyNameError + }} + +
+ +
+
+ +
+
+ +
+ + Property Name + + {{ renameError }} + +
+ + +
+
+
+ + +
+
+
+ `, + styles: [ + ` + .additional-properties { + display: flex; + flex-direction: column; + gap: 8px; + margin-top: 8px; + width: 100%; + } + .additional-properties-add, + .additional-property-row { + align-items: flex-start; + display: grid; + gap: 8px; + } + .additional-properties-add { + grid-template-columns: minmax(0, 1fr); + } + .additional-properties-add--with-title { + grid-template-columns: minmax(180px, max-content) minmax(0, 1fr); + } + .additional-properties-title { + align-self: center; + color: rgba(0, 0, 0, 0.6); + font-size: 14px; + } + .property-name-field { + min-width: 0; + width: 100%; + } + .property-name-error { + color: var(--mat-sys-error, #ba1a1a); + } + .additional-property-row { + grid-template-columns: minmax(0, 1fr) auto; + width: 100%; + } + .additional-property-control { + min-width: 0; + width: 100%; + } + .additional-property-actions { + align-items: center; + display: inline-flex; + flex-direction: column; + gap: 0; + opacity: 0.72; + transition: opacity 120ms ease; + } + .additional-property-row:hover .additional-property-actions { + opacity: 1; + } + .additional-property-action-button.mat-mdc-icon-button { + --mdc-icon-button-state-layer-size: 24px; + height: 24px; + padding: 2px; + width: 24px; + } + .additional-property-action-button mat-icon { + font-size: 16px; + height: 16px; + line-height: 16px; + width: 16px; + } + .additional-property-rename-menu { + box-sizing: border-box; + max-width: 100%; + overflow: hidden; + padding: 12px; + width: 280px; + } + .additional-property-rename-actions { + align-items: center; + display: flex; + gap: 8px; + justify-content: flex-end; + } + .rename-property-name-field { + width: 100%; + } + .additional-property-rename-panel.mat-mdc-menu-panel { + max-width: min(320px, calc(100vw - 32px)); + min-width: 0; + overflow-x: hidden; + } + .additional-property-rename-panel .mat-mdc-menu-content { + overflow-x: hidden; + padding: 0; + } + `, + ], + changeDetection: ChangeDetectionStrategy.OnPush, + encapsulation: ViewEncapsulation.None, + imports: [ + CommonModule, + FormsModule, + JsonFormsModule, + MatButtonModule, + MatFormFieldModule, + MatIconModule, + MatInputModule, + MatMenuModule, + MatTooltipModule, + ], +}) +export class AdditionalPropertiesRenderer implements OnChanges { + @Input() cells?: JsonFormsCellRendererRegistryEntry[]; + @Input() config?: any; + @Input() data: any; + @Input() enabled: boolean; + @Input() label: string; + @Input() path: string; + @Input() renderers?: JsonFormsRendererRegistryEntry[]; + @Input() rootSchema: JsonSchema; + @Input() schema: JsonSchema; + @Input() uischema: ControlElement; + + additionalKeys: string[] = []; + additionalPropertyItems: AdditionalPropertyItem[] = []; + additionalPropertiesTitle: string | undefined; + newPropertyName = ''; + propertyNameError: string | undefined; + renamingPropertyName: string | undefined; + renameValue = ''; + renameError: string | undefined; + shouldShow = false; + + private additionalPropertyItemCache = new Map< + string, + AdditionalPropertyItem + >(); + private jsonFormsService = inject(JsonFormsAngularService); + + ngOnChanges(changes: SimpleChanges): void { + if (changes.schema || changes.rootSchema || changes.path) { + this.additionalPropertyItemCache.clear(); + } + this.updateItems(); + } + + get addPropertyDisabled(): boolean { + return ( + !this.enabled || + Boolean(this.propertyNameError) || + !this.newPropertyName || + (this.config?.restrict && this.maxPropertiesReached) + ); + } + + get removePropertyDisabled(): boolean { + return ( + !this.enabled || (this.config?.restrict && this.minPropertiesReached) + ); + } + + get maxPropertiesReached(): boolean { + const objectSchema = toObjectSchema(this.schema); + return ( + objectSchema.maxProperties !== undefined && + this.data && + Object.keys(this.data).length >= objectSchema.maxProperties + ); + } + + get minPropertiesReached(): boolean { + const objectSchema = toObjectSchema(this.schema); + return ( + objectSchema.minProperties !== undefined && + this.data && + Object.keys(this.data).length <= objectSchema.minProperties + ); + } + + addProperty(): void { + this.propertyNameError = this.validatePropertyName(this.newPropertyName); + if (this.addPropertyDisabled) { + return; + } + + const additionalProperty = toAdditionalPropertyItem( + this.newPropertyName, + this.path, + this.schema, + this.rootSchema + ); + const updatedData = + typeof this.data === 'object' && + this.data !== null && + !Array.isArray(this.data) + ? { ...this.data } + : {}; + + updatedData[this.newPropertyName] = createDefaultValue( + additionalProperty.schema, + this.rootSchema + ); + this.jsonFormsService.updateCore( + Actions.update(this.path, () => updatedData) + ); + this.newPropertyName = ''; + this.propertyNameError = undefined; + } + + updatePropertyNameError(): void { + this.propertyNameError = this.validatePropertyName(this.newPropertyName); + } + + removeProperty(propertyName: string): void { + if ( + this.removePropertyDisabled || + typeof this.data !== 'object' || + this.data === null + ) { + return; + } + + const updatedData = { ...this.data }; + delete updatedData[propertyName]; + this.jsonFormsService.updateCore( + Actions.update(this.path, () => updatedData) + ); + } + + startRename(propertyName: string): void { + this.renamingPropertyName = propertyName; + this.renameValue = propertyName; + this.renameError = undefined; + } + + cancelRename(): void { + this.renamingPropertyName = undefined; + this.renameValue = ''; + this.renameError = undefined; + } + + renameDisabled(propertyName: string): boolean { + const trimmed = this.renameValue.trim(); + return ( + !this.enabled || + !trimmed || + trimmed === propertyName || + Boolean(this.validatePropertyName(trimmed, propertyName)) + ); + } + + updateRenameError(propertyName: string): void { + this.renameError = this.validatePropertyName( + this.renameValue.trim(), + propertyName + ); + } + + renameProperty(propertyName: string): void { + const trimmed = this.renameValue.trim(); + this.renameError = this.validatePropertyName(trimmed, propertyName); + + if ( + this.renameError || + !trimmed || + trimmed === propertyName || + typeof this.data !== 'object' || + this.data === null || + Array.isArray(this.data) + ) { + return; + } + + const updatedData = Object.fromEntries( + Object.entries(this.data).map(([key, value]) => [ + key === propertyName ? trimmed : key, + value, + ]) + ); + this.jsonFormsService.updateCore( + Actions.update(this.path, () => updatedData) + ); + this.cancelRename(); + } + + trackProperty(_index: number, item: AdditionalPropertyItem): string { + return item.propertyName; + } + + private updateItems(): void { + const objectSchema = toObjectSchema(this.schema); + const reservedPropertyNames = Object.keys(objectSchema.properties ?? {}); + this.additionalKeys = Object.keys(this.data ?? {}).filter( + (key) => !reservedPropertyNames.includes(key) + ); + this.additionalPropertyItems = this.additionalKeys.map((propertyName) => + this.getAdditionalPropertyItem(propertyName) + ); + const additionalKeySet = new Set(this.additionalKeys); + Array.from(this.additionalPropertyItemCache.keys()).forEach( + (propertyName) => { + if (!additionalKeySet.has(propertyName)) { + this.additionalPropertyItemCache.delete(propertyName); + } + } + ); + const allowIfMissing = + this.config?.allowAdditionalPropertiesIfMissing === true && + objectSchema.additionalProperties === undefined; + this.shouldShow = + hasAdditionalProperties(this.schema) || + allowIfMissing || + this.additionalKeys.length > 0; + this.additionalPropertiesTitle = toObjectSchema( + objectSchema.additionalProperties as JsonSchema + ).title; + this.propertyNameError = this.validatePropertyName(this.newPropertyName); + } + + private getAdditionalPropertyItem( + propertyName: string + ): AdditionalPropertyItem { + const cached = this.additionalPropertyItemCache.get(propertyName); + if (cached) { + return cached; + } + + const item = toAdditionalPropertyItem( + propertyName, + this.path, + this.schema, + this.rootSchema + ); + this.additionalPropertyItemCache.set(propertyName, item); + return item; + } + + private validatePropertyName( + propertyName: string, + currentPropertyName?: string + ): string | undefined { + if (!propertyName) { + return undefined; + } + + if ( + typeof this.data === 'object' && + this.data !== null && + Object.prototype.hasOwnProperty.call(this.data, propertyName) + ) { + if (propertyName === currentPropertyName) { + return undefined; + } + return `Property '${propertyName}' already defined`; + } + + if ( + propertyName.includes('[') || + propertyName.includes(']') || + propertyName.includes('.') + ) { + return `Property name '${propertyName}' is invalid`; + } + + const pattern = getPropertyNamePattern(this.schema); + if (pattern && !new RegExp(pattern).test(propertyName)) { + return `Property name must match pattern: ${pattern}`; + } + + return undefined; + } +} diff --git a/packages/angular-material/src/library/other/index.ts b/packages/angular-material/src/library/other/index.ts index ecdab3c25d..befe898f4f 100644 --- a/packages/angular-material/src/library/other/index.ts +++ b/packages/angular-material/src/library/other/index.ts @@ -24,5 +24,7 @@ */ export * from './label.renderer'; export * from './master-detail'; +export * from './additional-properties.renderer'; +export * from './mixed.renderer'; export * from './object.renderer'; export * from './table.renderer'; diff --git a/packages/angular-material/src/library/other/mixed.renderer.ts b/packages/angular-material/src/library/other/mixed.renderer.ts new file mode 100644 index 0000000000..2ca2ecf6ba --- /dev/null +++ b/packages/angular-material/src/library/other/mixed.renderer.ts @@ -0,0 +1,1678 @@ +/* + The MIT License + + Copyright (c) 2017-2026 EclipseSource Munich + https://github.com/eclipsesource/jsonforms + + Permission is hereby granted, free of charge, to any person obtaining a copy + of this software and associated documentation files (the "Software"), to deal + in the Software without restriction, including without limitation the rights + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + copies of the Software, and to permit persons to whom the Software is + furnished to do so, subject to the following conditions: + + The above copyright notice and this permission notice shall be included in + all copies or substantial portions of the Software. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + THE SOFTWARE. +*/ +import { CommonModule } from '@angular/common'; +import { FlatTreeControl } from '@angular/cdk/tree'; +import { + ChangeDetectionStrategy, + Component, + HostListener, + inject, +} from '@angular/core'; +import { FormsModule } from '@angular/forms'; +import { MatButtonModule } from '@angular/material/button'; +import { MatFormFieldModule } from '@angular/material/form-field'; +import { MatIconModule } from '@angular/material/icon'; +import { MatExpansionModule } from '@angular/material/expansion'; +import { MatInputModule } from '@angular/material/input'; +import { MatSelectModule } from '@angular/material/select'; +import { MatTooltipModule } from '@angular/material/tooltip'; +import { MatTreeModule } from '@angular/material/tree'; +import { + JsonFormsControl, + JsonFormsModule, + JsonFormsAngularService, +} from '@jsonforms/angular'; +import { + Actions, + compose, + ControlElement, + createControlElement, + createDefaultValue, + findUISchema, + isControl, + JsonFormsUISchemaRegistryEntry, + JsonSchema, + JsonSchema7, + rankWith, + RankedTester, + Resolve, + Scopable, + StatePropsOfControl, + TesterContext, + UISchemaElement, +} from '@jsonforms/core'; +import cloneDeep from 'lodash/cloneDeep'; +import get from 'lodash/get'; +import isEmpty from 'lodash/isEmpty'; + +type JsonDataType = + | 'array' + | 'boolean' + | 'integer' + | 'null' + | 'number' + | 'object' + | 'string'; + +interface SchemaRenderInfo { + schema: JsonSchema; + resolvedSchema: JsonSchema; + uischema: UISchemaElement; + label: string; + index: number; +} + +interface TreeNodeControl { + schema: JsonSchema; + uischema: ControlElement; + path: string; + label: string; +} + +interface MixedTreeNode { + nodeId: string; + title: string; + jsonType: JsonDataType; + label: string; + canRename: boolean; + canDelete: boolean; + control: TreeNodeControl; + children?: MixedTreeNode[]; +} + +interface VisibleTreeItem { + node: MixedTreeNode; + depth: number; + hasChildren: boolean; + open: boolean; +} + +const ROOT_TREE_NODE_ID = '$root'; +const ANY_TYPE: JsonDataType[] = [ + 'array', + 'boolean', + 'integer', + 'null', + 'number', + 'object', + 'string', +]; + +const toTreeNodeId = (path: string) => + path ? `$path:${path}` : ROOT_TREE_NODE_ID; + +const resolveSchema = (schema: JsonSchema, rootSchema: JsonSchema) => { + if (typeof schema === 'object' && typeof schema?.$ref === 'string') { + return Resolve.schema(rootSchema, schema.$ref, rootSchema) ?? schema; + } + return schema; +}; + +const cleanSchema = (schema: JsonSchema): JsonSchema => { + if (typeof schema !== 'object') { + return schema; + } + + 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; +}; + +const getJsonDataType = (value: any): JsonDataType | null => { + if (typeof value === 'string') { + return 'string'; + } + if (typeof value === 'number') { + return Number.isInteger(value) ? 'integer' : 'number'; + } + if (typeof value === 'boolean') { + return 'boolean'; + } + if (Array.isArray(value)) { + return 'array'; + } + if (value === null) { + return 'null'; + } + if (typeof value === 'object') { + return 'object'; + } + + return null; +}; + +const getSchemaTypesAsArray = (schema: JsonSchema): string[] => { + if (typeof schema !== 'object') { + return ANY_TYPE; + } + + 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 ANY_TYPE; +}; + +const createMixedRenderInfos = ( + schema: JsonSchema, + rootSchema: JsonSchema, + control: ControlElement, + path: string, + uischemas: JsonFormsUISchemaRegistryEntry[] +): SchemaRenderInfo[] => { + const resolvedSchemas: JsonSchema[] = []; + schema = resolveSchema(schema, rootSchema); + + if (typeof schema === 'object' && typeof schema.type === 'string') { + resolvedSchemas.push(schema); + } else { + getSchemaTypesAsArray(schema).forEach((type) => { + resolvedSchemas.push({ + ...(typeof schema === 'object' ? schema : {}), + type, + default: + typeof schema === 'object' && + schema.default !== undefined && + type === getJsonDataType(schema.default) + ? schema.default + : undefined, + }); + }); + } + + return resolvedSchemas + .map((resolvedSchema) => { + if ( + typeof resolvedSchema === 'object' && + resolvedSchema.type === 'array' + ) { + resolvedSchema.items = resolvedSchema.items ?? {}; + resolvedSchema.items = resolveSchema( + resolvedSchema.items as JsonSchema, + rootSchema + ); + + if ((resolvedSchema.items as any) === true) { + resolvedSchema.items = { type: ANY_TYPE }; + } else if ( + typeof (resolvedSchema.items as JsonSchema7).type !== 'string' && + !Array.isArray((resolvedSchema.items as JsonSchema7).type) + ) { + (resolvedSchema.items as JsonSchema7).type = ANY_TYPE; + } + } + + const cleanedSchema = cleanSchema(cloneDeep(resolvedSchema)); + const schemaType = + typeof cleanedSchema === 'object' ? cleanedSchema.type : undefined; + const detailsForSchema = control.options + ? control.options[`${schemaType}-detail`] + : undefined; + const schemaControl = detailsForSchema + ? { + ...control, + options: { ...control.options, detail: detailsForSchema }, + } + : control; + + const uischema = findUISchema( + uischemas, + cleanedSchema, + control.scope, + path, + () => createControlElement(control.scope ?? '#'), + schemaControl, + rootSchema + ); + + return { + schema: cleanedSchema, + resolvedSchema, + uischema, + label: `${schemaType}`, + }; + }) + .filter((info) => info.uischema) + .map((info, index) => ({ ...info, index })); +}; + +const findPropertySchema = ( + parentSchema: JsonSchema, + propName: string, + rootSchema: JsonSchema +): JsonSchema | undefined => { + if (typeof parentSchema !== 'object') { + return undefined; + } + + if (parentSchema.properties?.[propName]) { + return resolveSchema(parentSchema.properties[propName], rootSchema); + } + + if (parentSchema.patternProperties) { + const matchedPattern = Object.keys(parentSchema.patternProperties).find( + (pattern) => new RegExp(pattern).test(propName) + ); + + if (matchedPattern) { + return resolveSchema( + parentSchema.patternProperties[matchedPattern], + rootSchema + ); + } + } + + if (typeof parentSchema.additionalProperties === 'object') { + return resolveSchema(parentSchema.additionalProperties, rootSchema); + } + + if (parentSchema.additionalProperties === true) { + return { additionalProperties: true }; + } + + return undefined; +}; + +const getArrayItemSchema = ( + parentSchema: JsonSchema, + index: number, + rootSchema: JsonSchema +): JsonSchema | undefined => { + if (typeof parentSchema !== 'object' || !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({ ...(typeof schema === 'object' ? schema : {}), type: 'object' }) + ) as JsonSchema7; + objectSchema.additionalProperties = + objectSchema.additionalProperties !== false + ? objectSchema.additionalProperties ?? true + : false; + return objectSchema; +}; + +const prepareArraySchema = ( + schema: JsonSchema, + rootSchema: JsonSchema +): JsonSchema => { + const arraySchema = cleanSchema( + cloneDeep({ ...(typeof schema === 'object' ? schema : {}), type: 'array' }) + ) as JsonSchema7; + arraySchema.items = arraySchema.items ?? {}; + arraySchema.items = resolveSchema( + arraySchema.items as JsonSchema, + rootSchema + ) as JsonSchema7 | JsonSchema7[]; + + if ((arraySchema.items as any) === true) { + arraySchema.items = { type: ANY_TYPE }; + } else if ( + typeof (arraySchema.items as JsonSchema7).type !== 'string' && + !Array.isArray((arraySchema.items as JsonSchema7).type) + ) { + (arraySchema.items as JsonSchema7).type = ANY_TYPE; + } + + return arraySchema; +}; + +const createFallbackChildSchema = (title: string): JsonSchema => ({ + type: ANY_TYPE, + title, +}); + +const getSchemaDefaultType = (schema: JsonSchema): JsonDataType => { + const schemaTypes = getSchemaTypesAsArray(schema); + const firstType = + schemaTypes.find((type) => type !== 'null') ?? schemaTypes[0]; + return (firstType ?? 'object') as JsonDataType; +}; + +const prepareChildSchema = ( + childType: JsonDataType, + currentSchema: JsonSchema, + key: string, + index: number | null, + rootSchema: JsonSchema +): JsonSchema => { + let childSchema: JsonSchema | undefined; + + if (index !== null) { + childSchema = getArrayItemSchema(currentSchema, index, rootSchema); + childSchema = childSchema + ? { ...(childSchema as JsonSchema7), title: `Item ${index}` } + : createFallbackChildSchema(`Item ${index}`); + } else { + childSchema = findPropertySchema(currentSchema, key, rootSchema); + childSchema = childSchema + ? { ...(childSchema as JsonSchema7), title: key } + : createFallbackChildSchema(key); + } + + if ( + childType !== 'object' && + childType !== 'array' && + typeof childSchema === 'object' && + (!childSchema.type || (childSchema.type as any) === true) + ) { + childSchema.type = ANY_TYPE; + } + + if (childType === 'object') { + return prepareObjectSchema(childSchema); + } + + if (childType === 'array') { + return prepareArraySchema(childSchema, rootSchema); + } + + return childSchema; +}; + +const createTreeNodeControl = ( + schema: JsonSchema, + path: string, + label: string, + nodeType: JsonDataType, + controlCache: Map +): TreeNodeControl => { + const cacheKey = `${path}\u0000${nodeType}`; + const cached = controlCache.get(cacheKey); + if (cached) { + cached.label = label; + return cached; + } + + const control = { + schema, + uischema: createControlElement('#'), + path, + label, + }; + controlCache.set(cacheKey, control); + return control; +}; + +const withoutEmptyChildren = (node: MixedTreeNode): MixedTreeNode => { + const children = node.children?.map(withoutEmptyChildren) ?? []; + if (children.length === 0) { + const { children: _children, ...rest } = node; + return rest; + } + + return { + ...node, + children, + }; +}; + +const getDisplayTitle = (label: string, type: JsonDataType): string => { + if (label) { + return label; + } + return type === 'array' ? '[]' : '{}'; +}; + +const isDynamicProperty = (parentSchema: JsonSchema, key: string): boolean => + typeof parentSchema !== 'object' || !parentSchema.properties?.[key]; + +const buildTreeFromData = ( + data: any, + schema: JsonSchema, + rootSchema: JsonSchema, + path: string, + label: string, + showPrimitives: boolean, + controlCache: Map +): 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 + ) => { + 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, + currentLabel, + type, + controlCache + ), + children: [], + }; + children.push(node); + + Object.keys(value).forEach((key) => { + const childValue = value[key]; + const childPath = compose(currentPath, key); + const rawChildType = getJsonDataType(childValue); + const initialChildSchema = + findPropertySchema(currentSchema, key, rootSchema) ?? + createFallbackChildSchema(key); + const childType = + rawChildType ?? getSchemaDefaultType(initialChildSchema); + const childSchema = prepareChildSchema( + childType, + currentSchema, + key, + null, + rootSchema + ); + + if (childType === 'object' || childType === 'array') { + traverse( + childValue ?? (childType === 'array' ? [] : {}), + childPath, + key, + childSchema, + node.children, + isDynamicProperty(currentSchema, key), + true + ); + } else if (showPrimitives) { + node.children.push({ + nodeId: toTreeNodeId(childPath), + title: key, + jsonType: childType, + label: key, + canRename: isDynamicProperty(currentSchema, key), + canDelete: true, + control: createTreeNodeControl( + childSchema, + childPath, + key, + childType, + controlCache + ), + children: [], + }); + } + }); + } 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, + currentLabel, + type, + controlCache + ), + children: [], + }; + children.push(node); + + value.forEach((childValue: any, index: number) => { + const childType = getJsonDataType(childValue); + const childPath = compose(currentPath, `${index}`); + const childSchema = prepareChildSchema( + childType ?? 'object', + currentSchema, + '', + index, + rootSchema + ); + const resolvedChildType = + childType ?? getSchemaDefaultType(childSchema); + const childLabel = `Item ${index}`; + + 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, + childLabel, + resolvedChildType, + controlCache + ), + children: [], + }); + } + }); + } + }; + + traverse(data, path, label, resolveSchema(schema, rootSchema), nodes); + + return nodes.map(withoutEmptyChildren); +}; + +const flattenTree = (nodes: MixedTreeNode[]): MixedTreeNode[] => + nodes.flatMap((node) => [node, ...flattenTree(node.children ?? [])]); + +const getTreeStructureSignature = ( + value: any, + showPrimitives: boolean +): string => { + const type = getJsonDataType(value); + + if (type === 'object') { + return `o{${Object.keys(value) + .map((key) => { + const childType = getJsonDataType(value[key]); + if (childType === 'object' || childType === 'array') { + return `${key}:${getTreeStructureSignature( + value[key], + showPrimitives + )}`; + } + return showPrimitives ? `${key}:p` : ''; + }) + .filter(Boolean) + .join('|')}}`; + } + + if (type === 'array') { + return `a[${value + .map((childValue: any) => { + const childType = getJsonDataType(childValue); + if (childType === 'object' || childType === 'array') { + return getTreeStructureSignature(childValue, showPrimitives); + } + return showPrimitives ? 'p' : ''; + }) + .join('|')}]`; + } + + return showPrimitives ? 'p' : ''; +}; + +const flattenVisibleTree = ( + nodes: MixedTreeNode[], + openedNodeIds: Set, + depth = 0, + search = '' +): VisibleTreeItem[] => { + const normalizedSearch = search.trim().toLocaleLowerCase(); + const isSearching = Boolean(normalizedSearch); + + return nodes.flatMap((node) => { + const hasChildren = Boolean(node.children?.length); + const isOpened = hasChildren && openedNodeIds.has(node.nodeId); + const matchesSearch = + !normalizedSearch || + node.title.toLocaleLowerCase().includes(normalizedSearch) || + node.label.toLocaleLowerCase().includes(normalizedSearch); + const childItems = + hasChildren && (isSearching || isOpened) + ? flattenVisibleTree( + node.children ?? [], + openedNodeIds, + depth + 1, + normalizedSearch + ) + : []; + const hasMatchingChildren = childItems.length > 0; + + if (isSearching && !matchesSearch && !hasMatchingChildren) { + return []; + } + + const open = isSearching || isOpened; + const item: VisibleTreeItem = { node, depth, hasChildren, open }; + + return hasChildren && open ? [item, ...childItems] : [item]; + }); +}; + +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; +}; + +const getRelativePath = (rootPath: string, nodePath: string): string | null => { + if (nodePath === rootPath) { + return null; + } + return rootPath && nodePath.startsWith(`${rootPath}.`) + ? nodePath.slice(rootPath.length + 1) + : nodePath; +}; + +const getParentPath = (rootPath: string, nodePath: string): string => { + const lastDot = nodePath.lastIndexOf('.'); + return lastDot > 0 ? nodePath.substring(0, lastDot) : rootPath; +}; + +const schemaSupportsInputType = ( + schemaType: JsonSchema7['type'] | undefined, + dataType: JsonDataType | null +): boolean => { + if (!dataType || typeof schemaType !== 'string') { + return false; + } + + return ( + schemaType === dataType || + (schemaType === 'number' && dataType === 'integer') + ); +}; + +const isDefaultGenUiSchema = (uischema: UISchemaElement): boolean => { + const elements = (uischema as any)?.elements; + return ( + (uischema.type === 'VerticalLayout' || uischema.type === 'Group') && + Array.isArray(elements) && + elements.length === 1 && + elements[0].scope === '#' && + elements[0].type === 'Control' + ); +}; + +@Component({ + selector: 'MixedRenderer', + template: ` +
+ +
+ + {{ label }} + + None + + {{ info.label }} + + + {{ error }} + + {{ label }} + +
+ +
+
+
+ + +
+ + +
+ +
+ +
+
+
+ + + + + + + + + + + +
+ `, + styles: [ + ` + .mixed-renderer { + width: 100%; + } + .mixed-root-panel { + box-shadow: none; + } + .mixed-root-panel-header { + height: auto; + min-height: 64px; + } + .mixed-root-panel-header .mixed-header { + width: 100%; + } + .mixed-header { + align-items: flex-start; + display: flex; + gap: 8px; + } + .mixed-header-root { + align-items: center; + display: grid; + grid-template-columns: minmax(180px, 360px) minmax(0, 1fr); + } + .mixed-header-inline { + align-items: flex-start; + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + } + .mixed-header-detail { + align-items: flex-start; + display: grid; + grid-template-columns: minmax(180px, 280px) minmax(0, 1fr); + } + .mixed-type-selector, + .mixed-search, + .mixed-rename-field { + width: 100%; + } + .mixed-root-label { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + .mixed-tree-shell { + border: 1px solid rgba(0, 0, 0, 0.12); + display: flex; + min-height: 320px; + overflow: hidden; + } + .mixed-tree-pane { + box-sizing: border-box; + flex: 0 0 320px; + min-width: 220px; + max-width: 640px; + padding: 8px; + } + .mixed-tree-list { + max-height: 560px; + overflow: auto; + padding: 4px 0; + } + .mixed-tree-item { + align-items: center; + background: transparent; + border: 0; + border-radius: 4px; + box-sizing: border-box; + color: inherit; + cursor: pointer; + display: flex; + font: inherit; + min-height: 36px; + outline: none; + padding: 0 4px 0 0; + position: relative; + text-align: left; + user-select: none; + width: 100%; + } + .mixed-tree-item:hover { + background: rgba(0, 0, 0, 0.04); + } + .mixed-tree-item:focus-visible { + box-shadow: inset 0 0 0 2px #3f51b5; + } + .mixed-tree-item.active { + background: rgba(63, 81, 181, 0.12); + } + .mixed-tree-item:hover .mixed-hover-action, + .mixed-tree-item.active .mixed-hover-action { + opacity: 1; + } + .mixed-expand-button, + .mixed-expand-spacer { + flex: 0 0 32px; + } + .mixed-expand-button.mat-mdc-icon-button, + .mixed-tree-actions .mat-mdc-icon-button { + --mdc-icon-button-state-layer-size: 28px; + height: 28px; + padding: 2px; + width: 28px; + } + .mixed-type-icon { + align-items: center; + display: inline-flex; + flex: 0 0 28px; + font-size: 20px; + height: 24px; + justify-content: center; + width: 28px; + } + .mixed-tree-label { + flex: 1 1 auto; + min-width: 0; + overflow: hidden; + text-align: left; + text-overflow: ellipsis; + white-space: nowrap; + } + .mixed-rename-field { + flex: 1 1 auto; + } + .mixed-tree-actions { + align-items: center; + display: flex; + flex: 0 0 auto; + margin-left: 4px; + } + .mixed-hover-action { + opacity: 0; + } + .mixed-splitter { + background: rgba(0, 0, 0, 0.12); + cursor: col-resize; + flex: 0 0 6px; + } + .mixed-splitter.dragging { + background: #3f51b5; + } + .mixed-detail-pane { + flex: 1 1 auto; + min-width: 0; + padding: 16px; + } + .mixed-inline-detail { + min-width: 0; + } + `, + ], + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [ + CommonModule, + FormsModule, + JsonFormsModule, + MatButtonModule, + MatExpansionModule, + MatFormFieldModule, + MatIconModule, + MatInputModule, + MatSelectModule, + MatTooltipModule, + MatTreeModule, + ], +}) +export class MixedRenderer extends JsonFormsControl { + mixedRenderInfos: SchemaRenderInfo[] = []; + selectedIndex: number | undefined; + valueType: JsonDataType | null = null; + activeNodeId = ROOT_TREE_NODE_ID; + openedNodes = new Set(); + treeNodes: MixedTreeNode[] = []; + visibleTreeItems: VisibleTreeItem[] = []; + selectedNode: MixedTreeNode | undefined; + treeSearch = ''; + showPrimitivesInTree = false; + renamingNodeId: string | null = null; + renameValue = ''; + renameError: string | null = null; + treeWidth = 320; + treeExpanded = true; + draggingSplitter = false; + activeInfo: SchemaRenderInfo | undefined; + showTreeView = false; + isNestedComplexType = false; + showInlineDetail = false; + treeControl = new FlatTreeControl( + (item) => item.depth, + (item) => item.hasChildren, + { + trackBy: (item) => item.node.nodeId, + } + ); + + protected jsonFormsService = inject(JsonFormsAngularService); + parentMixedRenderer = inject(MixedRenderer, { + optional: true, + skipSelf: true, + }); + private treeControlCache = new Map(); + private treeStructureSignature: string | undefined; + private treeSchemaIndex: number | undefined; + + mapAdditionalProps(props: StatePropsOfControl): void { + const uischemas = + this.jsonFormsService.getState().jsonforms.uischemas ?? []; + this.valueType = getJsonDataType(this.data); + this.mixedRenderInfos = createMixedRenderInfos( + this.scopedSchema, + this.rootSchema, + this.uischema, + this.propsPath, + uischemas + ); + this.selectedIndex = this.findMatchingInfo()?.index; + this.activeInfo = + this.selectedIndex !== undefined + ? this.mixedRenderInfos[this.selectedIndex] + : undefined; + this.showTreeView = + !this.parentMixedRenderer && + (this.valueType === 'object' || this.valueType === 'array'); + this.isNestedComplexType = + Boolean(this.parentMixedRenderer) && + (this.valueType === 'object' || this.valueType === 'array'); + this.showInlineDetail = + !this.showTreeView && + !this.isNestedComplexType && + Boolean(this.activeInfo) && + this.activeInfo?.resolvedSchema?.type !== 'null'; + + if (this.showTreeView) { + const nextStructureSignature = getTreeStructureSignature( + this.data, + this.showPrimitivesInTree + ); + const nextSchemaIndex = this.activeInfo?.index; + if ( + this.treeNodes.length === 0 || + this.treeStructureSignature !== nextStructureSignature || + this.treeSchemaIndex !== nextSchemaIndex + ) { + this.rebuildTree(nextStructureSignature, nextSchemaIndex); + } + } else { + this.treeNodes = []; + this.visibleTreeItems = []; + this.selectedNode = undefined; + this.treeStructureSignature = undefined; + this.treeSchemaIndex = undefined; + } + + if (props.errors) { + this.error = props.errors; + } + } + + handleSelectChange(newIndex: number | undefined): void { + const newData = + newIndex !== undefined + ? createDefaultValue( + this.mixedRenderInfos[newIndex].resolvedSchema, + this.rootSchema + ) + : undefined; + + this.jsonFormsService.updateCore( + Actions.update(this.propsPath, () => newData) + ); + this.selectedIndex = newIndex; + this.activeInfo = + newIndex !== undefined ? this.mixedRenderInfos[newIndex] : undefined; + this.valueType = + newIndex !== undefined + ? ((this.mixedRenderInfos[newIndex]?.resolvedSchema as JsonSchema7) + ?.type as JsonDataType) + : null; + this.activeNodeId = toTreeNodeId(this.propsPath); + } + + selectPath(targetPath: string): void { + this.activeNodeId = toTreeNodeId(targetPath); + this.getPathAncestorNodeIds(targetPath).forEach((nodeId) => + this.openedNodes.add(nodeId) + ); + this.selectedNode = findNodeById(this.treeNodes, this.activeNodeId); + this.rebuildVisibleTree(); + } + + selectNestedPath(): void { + this.parentMixedRenderer?.selectPath(this.propsPath); + } + + selectNode(nodeId: string): void { + this.activeNodeId = nodeId; + this.selectedNode = findNodeById(this.treeNodes, nodeId); + } + + toggleNode(node: MixedTreeNode, event: MouseEvent): void { + event.stopPropagation(); + if (!node.children?.length) { + return; + } + + if (this.openedNodes.has(node.nodeId)) { + this.openedNodes.delete(node.nodeId); + } else { + this.openedNodes.add(node.nodeId); + } + this.rebuildVisibleTree(); + } + + togglePrimitiveVisibility(event: MouseEvent): void { + event.stopPropagation(); + this.showPrimitivesInTree = !this.showPrimitivesInTree; + this.rebuildTree(); + } + + startRename(node: MixedTreeNode, event: MouseEvent): void { + event.stopPropagation(); + if (!node.canRename) { + return; + } + this.renamingNodeId = node.nodeId; + this.renameValue = node.label; + this.renameError = null; + } + + cancelRename(): void { + this.renamingNodeId = null; + this.renameValue = ''; + this.renameError = null; + } + + commitRename(node: MixedTreeNode): void { + if (this.renamingNodeId !== node.nodeId) { + return; + } + + const trimmed = this.renameValue.trim(); + if (!trimmed || trimmed === node.label) { + this.cancelRename(); + return; + } + + const parentPath = getParentPath(this.propsPath, node.control.path); + const parentRelativePath = getRelativePath(this.propsPath, parentPath); + const parentData = + parentRelativePath === null + ? this.data + : get(this.data, parentRelativePath); + + if ( + typeof parentData !== 'object' || + parentData === null || + Array.isArray(parentData) + ) { + this.cancelRename(); + return; + } + + if (trimmed in parentData) { + this.renameError = `Property "${trimmed}" already exists`; + return; + } + + let parentSchema = this.getParentSchema(parentPath); + if (parentSchema) { + parentSchema = resolveSchema(parentSchema, this.rootSchema); + } + + if (typeof parentSchema === 'object' && parentSchema.patternProperties) { + const patterns = Object.keys(parentSchema.patternProperties); + const hadMatchingPattern = patterns.some((pattern) => + new RegExp(pattern).test(node.label) + ); + const hasMatchingPattern = patterns.some((pattern) => + new RegExp(pattern).test(trimmed) + ); + if (hadMatchingPattern && !hasMatchingPattern) { + this.renameError = `Property name must match pattern: ${patterns.join( + ', ' + )}`; + return; + } + } + + const propertyNames = (parentSchema as JsonSchema7)?.propertyNames as + | JsonSchema7 + | undefined; + if (propertyNames?.pattern) { + const pattern = new RegExp(propertyNames.pattern); + if (!pattern.test(trimmed)) { + this.renameError = `Property name must match pattern: ${propertyNames.pattern}`; + return; + } + } + + const updatedData = Object.fromEntries( + Object.entries(parentData).map(([key, value]) => [ + key === node.label ? trimmed : key, + value, + ]) + ); + this.jsonFormsService.updateCore( + Actions.update(parentPath, () => updatedData) + ); + + const newPath = compose(parentPath, trimmed); + this.selectPath(newPath); + this.cancelRename(); + } + + deleteNode(node: MixedTreeNode, event: MouseEvent): void { + event.stopPropagation(); + if (!node.canDelete) { + return; + } + + const parentPath = getParentPath(this.propsPath, node.control.path); + const parentRelativePath = getRelativePath(this.propsPath, parentPath); + const parentData = + parentRelativePath === null + ? this.data + : get(this.data, parentRelativePath); + const key = node.control.path.slice( + parentPath.length ? parentPath.length + 1 : 0 + ); + + if (Array.isArray(parentData)) { + const index = Number(key); + if (!Number.isInteger(index)) { + return; + } + const updatedData = [...parentData]; + updatedData.splice(index, 1); + this.jsonFormsService.updateCore( + Actions.update(parentPath, () => updatedData) + ); + } else if (typeof parentData === 'object' && parentData !== null) { + const updatedData = { ...parentData }; + delete updatedData[key]; + this.jsonFormsService.updateCore( + Actions.update(parentPath, () => updatedData) + ); + } + + if ( + this.activeNodeId === node.nodeId || + this.activeNodeId.startsWith(`${node.nodeId}.`) + ) { + this.selectPath(parentPath); + } + } + + rebuildVisibleTree(): void { + this.visibleTreeItems = flattenVisibleTree( + this.treeNodes, + this.openedNodes, + 0, + this.treeSearch + ); + } + + trackTreeItem(_index: number, item: VisibleTreeItem): string { + return item.node.nodeId; + } + + getTypeIcon(type: JsonDataType): string { + switch (type) { + case 'array': + return 'format_list_bulleted'; + case 'object': + return 'folder'; + case 'boolean': + return 'toggle_on'; + case 'integer': + case 'number': + return 'pin'; + case 'string': + return 'abc'; + case 'null': + default: + return 'add'; + } + } + + startSplitterDrag(event: MouseEvent): void { + event.preventDefault(); + this.draggingSplitter = true; + } + + @HostListener('document:mousemove', ['$event']) + onDocumentMouseMove(event: MouseEvent): void { + if (!this.draggingSplitter) { + return; + } + this.treeWidth = Math.min(640, Math.max(220, event.clientX - 32)); + } + + @HostListener('document:mouseup') + onDocumentMouseUp(): void { + this.draggingSplitter = false; + } + + private findMatchingInfo(): SchemaRenderInfo | undefined { + const currentlySelected = + this.selectedIndex !== undefined + ? this.mixedRenderInfos[this.selectedIndex] + : undefined; + if ( + currentlySelected && + schemaSupportsInputType( + (currentlySelected.resolvedSchema as JsonSchema7).type, + this.valueType + ) + ) { + return currentlySelected; + } + + const exact = this.mixedRenderInfos.find( + (entry) => (entry.resolvedSchema as JsonSchema7).type === this.valueType + ); + return ( + exact ?? + this.mixedRenderInfos.find( + (entry) => + (entry.resolvedSchema as JsonSchema7).type === 'number' && + this.valueType === 'integer' + ) + ); + } + + private rebuildTree( + structureSignature = getTreeStructureSignature( + this.data, + this.showPrimitivesInTree + ), + schemaIndex = this.activeInfo?.index + ): void { + this.treeNodes = buildTreeFromData( + this.data, + this.activeInfo?.resolvedSchema ?? this.scopedSchema, + this.rootSchema, + this.propsPath, + this.label, + this.showPrimitivesInTree, + this.treeControlCache + ); + this.treeStructureSignature = structureSignature; + this.treeSchemaIndex = schemaIndex; + + const allNodeIds = flattenTree(this.treeNodes).map((node) => node.nodeId); + const allNodeIdSet = new Set(allNodeIds); + const rootNodeId = toTreeNodeId(this.propsPath); + if (!allNodeIdSet.has(this.activeNodeId)) { + this.activeNodeId = rootNodeId; + } + this.openedNodes = new Set( + [rootNodeId, ...Array.from(this.openedNodes)].filter((nodeId) => + allNodeIdSet.has(nodeId) + ) + ); + this.selectedNode = findNodeById(this.treeNodes, this.activeNodeId); + this.rebuildVisibleTree(); + } + + private getPathAncestorNodeIds(targetPath: string): string[] { + const relativePath = getRelativePath(this.propsPath, targetPath); + const segments = + relativePath === null ? [] : relativePath.split('.').filter(Boolean); + const result = [toTreeNodeId(this.propsPath)]; + let currentPath = this.propsPath; + + segments.slice(0, -1).forEach((segment) => { + currentPath = compose(currentPath, segment); + result.push(toTreeNodeId(currentPath)); + }); + + return result; + } + + private getParentSchema(parentPath: string): JsonSchema | undefined { + const parentRelativePath = getRelativePath(this.propsPath, parentPath); + if (parentRelativePath === null) { + return this.activeInfo?.resolvedSchema ?? this.scopedSchema; + } + + const segments = parentRelativePath.split('.'); + let currentSchema: JsonSchema = + this.activeInfo?.resolvedSchema ?? this.scopedSchema; + + for (const segment of segments) { + currentSchema = resolveSchema(currentSchema, this.rootSchema); + if (typeof currentSchema !== 'object') { + return {}; + } + if (currentSchema.type === 'array') { + currentSchema = (currentSchema.items as JsonSchema) ?? {}; + } else { + currentSchema = + currentSchema.properties?.[segment] ?? + findPropertySchema(currentSchema, segment, this.rootSchema) ?? + {}; + } + } + + return currentSchema; + } +} + +export const isMixedSchema = ( + uischema: UISchemaElement & Scopable, + schema: JsonSchema, + context: TesterContext +) => { + if (schema && typeof schema === 'boolean') { + return true; + } + + if (!schema || typeof schema !== 'object') { + return false; + } + + if (Array.isArray(schema.type)) { + return true; + } + + if (schema.type === 'object') { + const schemaPath = uischema.scope; + if (schemaPath && !isEmpty(schemaPath)) { + const currentDataSchema = Resolve.schema( + schema, + schemaPath, + context?.rootSchema + ); + if (currentDataSchema === undefined) { + return false; + } + if (Array.isArray(currentDataSchema.type)) { + return true; + } + } + } + + return false; +}; + +export const isMixedControl = ( + uischema: UISchemaElement, + schema: JsonSchema, + context: TesterContext +) => + isMixedSchema(uischema as UISchemaElement & Scopable, schema, context) && + (isControl(uischema) || isDefaultGenUiSchema(uischema)); + +export const MixedRendererTester: RankedTester = rankWith(20, isMixedControl); diff --git a/packages/angular-material/src/library/other/object.renderer.ts b/packages/angular-material/src/library/other/object.renderer.ts index 1585c71484..ea43e22ed9 100644 --- a/packages/angular-material/src/library/other/object.renderer.ts +++ b/packages/angular-material/src/library/other/object.renderer.ts @@ -30,6 +30,7 @@ import { JsonFormsModule, } from '@jsonforms/angular'; import { MatCardModule } from '@angular/material/card'; +import { AdditionalPropertiesRenderer } from './additional-properties.renderer'; import { ControlWithDetailProps, findUISchema, @@ -47,12 +48,25 @@ import cloneDeep from 'lodash/cloneDeep'; selector: 'ObjectRenderer', template: ` + + {{ objectLabel }} + + `, styles: [ @@ -60,14 +74,23 @@ import cloneDeep from 'lodash/cloneDeep'; .object-layout { padding: 16px; } + .object-layout-title { + margin-bottom: 16px; + } `, ], changeDetection: ChangeDetectionStrategy.OnPush, - imports: [JsonFormsModule, MatCardModule], + imports: [JsonFormsModule, MatCardModule, AdditionalPropertiesRenderer], }) export class ObjectControlRenderer extends JsonFormsControlWithDetail { detailUiSchema: UISchemaElement; + additionalPropertiesConfig: any; + objectLabel: string | undefined; mapAdditionalProps(props: ControlWithDetailProps) { + this.additionalPropertiesConfig = { + ...props.config, + ...props.uischema.options, + }; this.detailUiSchema = findUISchema( props.uischemas, props.schema, @@ -81,7 +104,7 @@ export class ObjectControlRenderer extends JsonFormsControlWithDetail { delete newSchema.allOf; return Generate.uiSchema( newSchema, - 'Group', + 'VerticalLayout', undefined, this.rootSchema ); @@ -91,8 +114,16 @@ export class ObjectControlRenderer extends JsonFormsControlWithDetail { ); if (isEmpty(props.path)) { this.detailUiSchema.type = 'VerticalLayout'; + this.objectLabel = undefined; } else { - (this.detailUiSchema as GroupLayout).label = startCase(props.path); + this.objectLabel = + (this.detailUiSchema as GroupLayout).label ?? startCase(props.path); + if (this.detailUiSchema.type === 'Group') { + this.detailUiSchema = { + ...this.detailUiSchema, + type: 'VerticalLayout', + } as UISchemaElement; + } } if (!this.isEnabled()) { setReadonly(this.detailUiSchema); diff --git a/packages/angular/src/library/abstract-control.ts b/packages/angular/src/library/abstract-control.ts index 6202e31994..aa67efdb09 100644 --- a/packages/angular/src/library/abstract-control.ts +++ b/packages/angular/src/library/abstract-control.ts @@ -33,6 +33,7 @@ import { Actions, computeLabel, ControlElement, + createDefaultValue, Id, JsonFormsState, JsonSchema, @@ -56,6 +57,7 @@ export abstract class JsonFormsAbstractControl< @Input() id: string; @Input() disabled: boolean; @Input() visible: boolean; + @Input() preserveUndefinedAsDefault = false; form: FormControl; data: any; @@ -87,8 +89,14 @@ export abstract class JsonFormsAbstractControl< getEventValue = (event: any) => event.value; onChange(ev: any) { + const eventValue = this.getEventValue(ev); + const value = + this.preserveUndefinedAsDefault && eventValue === undefined + ? createDefaultValue(this.scopedSchema, this.rootSchema) + : eventValue; + this.jsonFormsService.updateCore( - Actions.update(this.propsPath, () => this.getEventValue(ev)) + Actions.update(this.propsPath, () => value) ); this.triggerValidation(); } diff --git a/packages/angular/src/library/jsonforms.component.ts b/packages/angular/src/library/jsonforms.component.ts index e8fec800e5..28a2587a9f 100644 --- a/packages/angular/src/library/jsonforms.component.ts +++ b/packages/angular/src/library/jsonforms.component.ts @@ -27,7 +27,9 @@ import { Directive, inject, Input, + OnChanges, OnInit, + SimpleChanges, Type, ViewContainerRef, } from '@angular/core'; @@ -70,13 +72,15 @@ const areEqual = ( }) export class JsonFormsOutlet extends JsonFormsBaseRenderer - implements OnInit + implements OnInit, OnChanges { private previousProps: StatePropsOfJsonFormsRenderer; private viewContainerRef = inject(ViewContainerRef); private jsonformsService = inject(JsonFormsAngularService); + @Input() preserveUndefinedAsDefault = false; + @Input() set renderProps(renderProps: OwnPropsOfRenderer) { this.path = renderProps.path; @@ -85,6 +89,21 @@ export class JsonFormsOutlet this.update(this.jsonformsService.getState()); } + ngOnChanges(changes: SimpleChanges): void { + if (changes.renderProps) { + return; + } + + if ( + changes.schema || + changes.uischema || + changes.path || + changes.preserveUndefinedAsDefault + ) { + this.update(this.jsonformsService.getState()); + } + } + ngOnInit(): void { this.addSubscription( this.jsonformsService.$state.subscribe({ @@ -135,6 +154,8 @@ export class JsonFormsOutlet instance.path = this.path; if (instance instanceof JsonFormsControl) { const controlInstance = instance as JsonFormsControl; + controlInstance.preserveUndefinedAsDefault = + this.preserveUndefinedAsDefault; if (controlInstance.id === undefined) { const id = isControl(props.uischema) ? Id.createId(props.uischema.scope) diff --git a/packages/material-renderers/package.json b/packages/material-renderers/package.json index bd4ffa6e00..107a1c23a6 100644 --- a/packages/material-renderers/package.json +++ b/packages/material-renderers/package.json @@ -98,6 +98,7 @@ "@mui/icons-material": "^7.0.0", "@mui/material": "^7.0.0", "@mui/x-date-pickers": "^8.0.0", + "@mui/x-tree-view": "^8.0.0", "react": "^16.12.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" }, "devDependencies": { @@ -108,6 +109,7 @@ "@mui/icons-material": "^7.3.0", "@mui/material": "^7.3.0", "@mui/x-date-pickers": "^8.11.3", + "@mui/x-tree-view": "^8.29.0", "@rollup/plugin-commonjs": "^23.0.3", "@rollup/plugin-json": "^5.0.2", "@rollup/plugin-node-resolve": "^15.0.1", diff --git a/packages/material-renderers/src/complex/DynamicPropertyDispatch.tsx b/packages/material-renderers/src/complex/DynamicPropertyDispatch.tsx new file mode 100644 index 0000000000..de55fb84b6 --- /dev/null +++ b/packages/material-renderers/src/complex/DynamicPropertyDispatch.tsx @@ -0,0 +1,111 @@ +/* + The MIT License + + Copyright (c) 2017-2026 EclipseSource Munich + https://github.com/eclipsesource/jsonforms + + Permission is hereby granted, free of charge, to any person obtaining a copy + of this software and associated documentation files (the "Software"), to deal + in the Software without restriction, including without limitation the rights + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + copies of the Software, and to permit persons to whom the Software is + furnished to do so, subject to the following conditions: + + The above copyright notice and this permission notice shall be included in + all copies or substantial portions of the Software. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + THE SOFTWARE. +*/ +import React, { useMemo } from 'react'; +import { + CoreActions, + createDefaultValue, + JsonFormsCellRendererRegistryEntry, + JsonFormsRendererRegistryEntry, + JsonSchema, + UPDATE_DATA, + UpdateAction, + UISchemaElement, +} from '@jsonforms/core'; +import { + JsonFormsContext, + JsonFormsDispatch, + useJsonForms, +} from '@jsonforms/react'; + +interface DynamicPropertyDispatchProps { + cells?: JsonFormsCellRendererRegistryEntry[]; + enabled?: boolean; + path: string; + readonly?: boolean; + renderers?: JsonFormsRendererRegistryEntry[]; + rootSchema: JsonSchema; + schema: JsonSchema; + uischema: UISchemaElement; +} + +const isUpdateAction = (action: CoreActions): action is UpdateAction => + action.type === UPDATE_DATA; + +export const DynamicPropertyDispatch = ({ + cells, + enabled, + path, + readonly, + renderers, + rootSchema, + schema, + uischema, +}: DynamicPropertyDispatchProps) => { + const jsonforms = useJsonForms(); + const dispatch = useMemo(() => { + if (!jsonforms.dispatch) { + return jsonforms.dispatch; + } + + return (action: CoreActions) => { + if (isUpdateAction(action) && action.path === path) { + jsonforms.dispatch?.({ + ...action, + updater: (existingData: any) => { + const value = action.updater(existingData); + return value === undefined + ? createDefaultValue(schema, rootSchema) + : value; + }, + }); + return; + } + + jsonforms.dispatch?.(action); + }; + }, [jsonforms, path, rootSchema, schema]); + + const contextValue = useMemo( + () => ({ + ...jsonforms, + dispatch, + }), + [dispatch, jsonforms] + ); + + return ( + + + + ); +}; diff --git a/packages/material-renderers/src/complex/MaterialAdditionalProperties.tsx b/packages/material-renderers/src/complex/MaterialAdditionalProperties.tsx new file mode 100644 index 0000000000..d8e47c5026 --- /dev/null +++ b/packages/material-renderers/src/complex/MaterialAdditionalProperties.tsx @@ -0,0 +1,609 @@ +/* + The MIT License + + Copyright (c) 2017-2026 EclipseSource Munich + https://github.com/eclipsesource/jsonforms + + Permission is hereby granted, free of charge, to any person obtaining a copy + of this software and associated documentation files (the "Software"), to deal + in the Software without restriction, including without limitation the rights + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + copies of the Software, and to permit persons to whom the Software is + furnished to do so, subject to the following conditions: + + The above copyright notice and this permission notice shall be included in + all copies or substantial portions of the Software. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + THE SOFTWARE. +*/ +import React, { useMemo, useState } from 'react'; +import { + composePaths, + ControlElement, + createControlElement, + createDefaultValue, + Generate, + GroupLayout, + JsonFormsCellRendererRegistryEntry, + JsonFormsRendererRegistryEntry, + JsonFormsUISchemaRegistryEntry, + JsonSchema, + JsonSchema7, + resolveSchema, + UISchemaElement, +} from '@jsonforms/core'; +import { + Box, + Button, + IconButton, + InputAdornment, + Popover, + TextField, + Tooltip, + Typography, +} from '@mui/material'; +import { Add, DeleteOutline, EditOutlined } from '@mui/icons-material'; +import merge from 'lodash/merge'; +import startCase from 'lodash/startCase'; +import { useInputVariant } from '../util'; +import { DynamicPropertyDispatch } from './DynamicPropertyDispatch'; + +interface AdditionalPropertyItem { + propertyName: string; + path: string; + schema: JsonSchema; + uischema: UISchemaElement; +} + +export interface MaterialAdditionalPropertiesProps { + cells?: JsonFormsCellRendererRegistryEntry[]; + config?: any; + data: any; + enabled: boolean; + handleChange(path: string, value: any): void; + label: string; + path: string; + readonly?: boolean; + renderers?: JsonFormsRendererRegistryEntry[]; + rootSchema: JsonSchema; + schema: JsonSchema; + uischema: ControlElement; + uischemas?: JsonFormsUISchemaRegistryEntry[]; +} + +const ANY_TYPE: JsonSchema7['type'] = [ + 'array', + 'boolean', + 'integer', + 'null', + 'number', + 'object', + 'string', +]; + +const toObjectSchema = (schema: JsonSchema): JsonSchema7 => + typeof schema === 'object' ? (schema as JsonSchema7) : {}; + +const hasAdditionalProperties = (schema: JsonSchema): boolean => { + const objectSchema = toObjectSchema(schema); + return ( + Boolean( + objectSchema.patternProperties && + Object.keys(objectSchema.patternProperties).length > 0 + ) || + typeof objectSchema.additionalProperties === 'object' || + objectSchema.additionalProperties === true + ); +}; + +const getMatchingAdditionalPropertySchema = ( + propName: string, + parentSchema: JsonSchema, + rootSchema: JsonSchema +): JsonSchema => { + const objectSchema = toObjectSchema(parentSchema); + let propSchema: JsonSchema | undefined; + + if (objectSchema.patternProperties) { + const matchedPattern = Object.keys(objectSchema.patternProperties).find( + (pattern) => new RegExp(pattern).test(propName) + ); + if (matchedPattern) { + propSchema = objectSchema.patternProperties[matchedPattern]; + } + } + + if ( + (!propSchema && typeof objectSchema.additionalProperties === 'object') || + objectSchema.additionalProperties === true + ) { + propSchema = + objectSchema.additionalProperties === true + ? { additionalProperties: true } + : objectSchema.additionalProperties; + } + + if (typeof propSchema === 'object' && typeof propSchema.$ref === 'string') { + propSchema = resolveSchema(rootSchema, propSchema.$ref, rootSchema); + } + + propSchema = propSchema ?? {}; + + if (typeof propSchema === 'object' && propSchema.type === undefined) { + propSchema = { + ...propSchema, + type: ANY_TYPE, + }; + } + + return propSchema; +}; + +const toAdditionalPropertyItem = ( + propName: string, + parentPath: string, + parentSchema: JsonSchema, + rootSchema: JsonSchema +): AdditionalPropertyItem => { + let propSchema = getMatchingAdditionalPropertySchema( + propName, + parentSchema, + rootSchema + ); + let propUiSchema: UISchemaElement = createControlElement('#'); + + if (typeof propSchema === 'object' && propSchema.type === 'array') { + propUiSchema = Generate.uiSchema( + propSchema, + 'Group', + undefined, + rootSchema + ); + (propUiSchema as GroupLayout).label = + propSchema.title ?? startCase(propName); + } + + if (typeof propSchema === 'object') { + propSchema = { + ...propSchema, + title: propName, + }; + if (propSchema.type === 'object') { + propSchema.additionalProperties = + propSchema.additionalProperties !== false + ? propSchema.additionalProperties ?? true + : false; + } else if (propSchema.type === 'array') { + propSchema.items = propSchema.items ?? {}; + } + } + + return { + propertyName: propName, + path: composePaths(parentPath, propName), + schema: propSchema, + uischema: propUiSchema, + }; +}; + +const getPropertyNamePattern = (schema: JsonSchema): string | undefined => { + const objectSchema = toObjectSchema(schema); + const propertyNames = objectSchema.propertyNames as JsonSchema7 | undefined; + if (typeof propertyNames === 'object' && propertyNames.pattern) { + return propertyNames.pattern; + } + + if ( + objectSchema.additionalProperties === false && + objectSchema.patternProperties + ) { + const patterns = Object.keys(objectSchema.patternProperties); + return patterns.length > 0 ? patterns.join('|') : undefined; + } + + return undefined; +}; + +const validatePropertyName = ( + propertyName: string, + data: any, + schema: JsonSchema, + currentPropertyName?: string +): string | undefined => { + if (!propertyName) { + return undefined; + } + + if ( + typeof data === 'object' && + data !== null && + Object.prototype.hasOwnProperty.call(data, propertyName) + ) { + if (propertyName === currentPropertyName) { + return undefined; + } + return `Property '${propertyName}' already defined`; + } + + if ( + propertyName.includes('[') || + propertyName.includes(']') || + propertyName.includes('.') + ) { + return `Property name '${propertyName}' is invalid`; + } + + const pattern = getPropertyNamePattern(schema); + if (pattern && !new RegExp(pattern).test(propertyName)) { + return `Property name must match pattern: ${pattern}`; + } + + return undefined; +}; + +export const MaterialAdditionalProperties = ({ + cells, + config, + data, + enabled, + handleChange, + label, + path, + readonly, + renderers, + rootSchema, + schema, + uischema, +}: MaterialAdditionalPropertiesProps) => { + const [newPropertyName, setNewPropertyName] = useState(''); + const [renamingPropertyName, setRenamingPropertyName] = useState< + string | null + >(null); + const [renameAnchorEl, setRenameAnchorEl] = useState( + null + ); + const [renameValue, setRenameValue] = useState(''); + const inputVariant = useInputVariant(); + const appliedOptions = merge({}, config, uischema.options); + const objectSchema = toObjectSchema(schema); + const reservedPropertyNames = Object.keys(objectSchema.properties ?? {}); + const additionalKeys = Object.keys(data ?? {}).filter( + (key) => !reservedPropertyNames.includes(key) + ); + const additionalPropertyItems = useMemo( + () => + additionalKeys.map((propertyName) => + toAdditionalPropertyItem(propertyName, path, schema, rootSchema) + ), + [additionalKeys.join('\u0000'), path, rootSchema, schema] + ); + const allowIfMissing = + appliedOptions.allowAdditionalPropertiesIfMissing === true && + objectSchema.additionalProperties === undefined; + const shouldShow = + hasAdditionalProperties(schema) || + allowIfMissing || + additionalKeys.length > 0; + + if (!shouldShow) { + return null; + } + + const propertyNameError = validatePropertyName(newPropertyName, data, schema); + const maxPropertiesReached = + objectSchema.maxProperties !== undefined && + data && + Object.keys(data).length >= objectSchema.maxProperties; + const minPropertiesReached = + objectSchema.minProperties !== undefined && + data && + Object.keys(data).length <= objectSchema.minProperties; + const addPropertyDisabled = + !enabled || + readonly || + (appliedOptions.restrict && maxPropertiesReached) || + Boolean(propertyNameError) || + !newPropertyName; + const removePropertyDisabled = + !enabled || readonly || (appliedOptions.restrict && minPropertiesReached); + const additionalPropertiesTitle = toObjectSchema( + objectSchema.additionalProperties as JsonSchema + ).title; + + const addProperty = () => { + if (addPropertyDisabled) { + return; + } + + const additionalProperty = toAdditionalPropertyItem( + newPropertyName, + path, + schema, + rootSchema + ); + const updatedData = + typeof data === 'object' && data !== null && !Array.isArray(data) + ? { ...data } + : {}; + + updatedData[newPropertyName] = createDefaultValue( + additionalProperty.schema, + rootSchema + ); + handleChange(path, updatedData); + setNewPropertyName(''); + }; + + const removeProperty = (propertyName: string) => { + if (removePropertyDisabled || typeof data !== 'object' || data === null) { + return; + } + + const updatedData = { ...data }; + delete updatedData[propertyName]; + handleChange(path, updatedData); + }; + const renameProperty = (propertyName: string) => { + const trimmed = renameValue.trim(); + const renameError = validatePropertyName( + trimmed, + data, + schema, + propertyName + ); + if ( + renameError || + !trimmed || + trimmed === propertyName || + typeof data !== 'object' || + data === null || + Array.isArray(data) + ) { + return; + } + + const updatedData = Object.fromEntries( + Object.entries(data).map(([key, value]) => [ + key === propertyName ? trimmed : key, + value, + ]) + ); + handleChange(path, updatedData); + setRenamingPropertyName(null); + setRenameAnchorEl(null); + setRenameValue(''); + }; + const cancelRename = () => { + setRenamingPropertyName(null); + setRenameAnchorEl(null); + setRenameValue(''); + }; + const startRename = (propertyName: string, anchorEl: HTMLElement | null) => { + setRenamingPropertyName(propertyName); + setRenameAnchorEl(anchorEl); + setRenameValue(propertyName); + }; + + return ( + + + {additionalPropertiesTitle ? ( + + {additionalPropertiesTitle} + + ) : null} + setNewPropertyName(event.target.value)} + onKeyDown={(event) => { + if (event.key === 'Enter') { + addProperty(); + } + }} + InputProps={{ + endAdornment: ( + + + + + + + + + + ), + }} + /> + + + {additionalPropertyItems.map((item) => { + const isRenaming = renamingPropertyName === item.propertyName; + const renameError = validatePropertyName( + renameValue.trim(), + data, + schema, + item.propertyName + ); + const renameDisabled = + !enabled || + readonly || + Boolean(renameError) || + !renameValue.trim() || + renameValue.trim() === item.propertyName; + + return ( + + + + + {enabled ? ( + + + + + startRename(item.propertyName, event.currentTarget) + } + > + + + + + + + removeProperty(item.propertyName)} + > + + + + + + ) : null} + + + setRenameValue(event.target.value)} + onKeyDown={(event) => { + if (event.key === 'Enter') { + renameProperty(item.propertyName); + } else if (event.key === 'Escape') { + cancelRename(); + } + }} + /> + + + + + + + + ); + })} + + + ); +}; diff --git a/packages/material-renderers/src/complex/MaterialMixedRenderer.tsx b/packages/material-renderers/src/complex/MaterialMixedRenderer.tsx new file mode 100644 index 0000000000..ab8e2544fc --- /dev/null +++ b/packages/material-renderers/src/complex/MaterialMixedRenderer.tsx @@ -0,0 +1,1777 @@ +/* + The MIT License + + Copyright (c) 2017-2026 EclipseSource Munich + https://github.com/eclipsesource/jsonforms + + Permission is hereby granted, free of charge, to any person obtaining a copy + of this software and associated documentation files (the "Software"), to deal + in the Software without restriction, including without limitation the rights + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + copies of the Software, and to permit persons to whom the Software is + furnished to do so, subject to the following conditions: + + The above copyright notice and this permission notice shall be included in + all copies or substantial portions of the Software. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + THE SOFTWARE. +*/ +import React, { + createContext, + useCallback, + useContext, + useEffect, + useMemo, + useRef, + useState, +} from 'react'; +import { + compose, + ControlElement, + ControlProps, + createControlElement, + createDefaultValue, + findUISchema, + isControl, + JsonFormsUISchemaRegistryEntry, + JsonSchema, + JsonSchema7, + rankWith, + RankedTester, + resolveSchema as resolveSchemaCore, + Scopable, + TesterContext, + UISchemaElement, +} from '@jsonforms/core'; +import { useJsonForms, withJsonFormsControlProps } from '@jsonforms/react'; +import { + Accordion, + AccordionDetails, + AccordionSummary, + Box, + Collapse, + Divider, + FormControl, + IconButton, + InputAdornment, + InputLabel, + MenuItem, + Select, + TextField, + Tooltip, + Typography, +} from '@mui/material'; +import { SimpleTreeView, TreeItem } from '@mui/x-tree-view'; +import { + Abc, + Add, + DeleteOutline, + EditOutlined, + ExpandMore, + FolderOutlined, + FormatListBulleted, + Numbers, + Search, + ToggleOnOutlined, + Visibility, + VisibilityOff, +} from '@mui/icons-material'; +import cloneDeep from 'lodash/cloneDeep'; +import get from 'lodash/get'; +import isEmpty from 'lodash/isEmpty'; +import isEqual from 'lodash/isEqual'; +import set from 'lodash/set'; +import { useInputVariant } from '../util'; +import { DynamicPropertyDispatch } from './DynamicPropertyDispatch'; + +type JsonDataType = + | 'array' + | 'boolean' + | 'integer' + | 'null' + | 'number' + | 'object' + | 'string'; + +interface SchemaRenderInfo { + schema: JsonSchema; + resolvedSchema: JsonSchema; + uischema: UISchemaElement; + label: string; + index: number; +} + +interface TreeNodeControl { + id: string; + schema: JsonSchema; + uischema: ControlElement; + path: string; + label: string; + required: boolean; + enabled: boolean; + readonly?: boolean; +} + +interface MixedTreeNode { + nodeId: string; + title: string; + jsonType: JsonDataType; + label: string; + canRename: boolean; + canDelete: boolean; + control: TreeNodeControl; + children?: MixedTreeNode[]; +} + +interface NavigationContext { + selectPath: (path: string) => void; +} + +const MixedNavigationContext = createContext( + undefined +); + +const EMPTY_UISCHEMAS: JsonFormsUISchemaRegistryEntry[] = []; +const ROOT_TREE_NODE_ID = '$root'; +const toTreeNodeId = (path: string) => + path ? `$path:${path}` : ROOT_TREE_NODE_ID; + +const resolveSchema = (schema: JsonSchema, rootSchema: JsonSchema) => { + if (typeof schema === 'object' && typeof schema?.$ref === 'string') { + return resolveSchemaCore(rootSchema, schema.$ref, rootSchema) ?? schema; + } + return schema; +}; + +const cleanSchema = (schema: JsonSchema): JsonSchema => { + if (typeof schema !== 'object') { + return schema; + } + + 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; +}; + +const getJsonDataType = (value: any): JsonDataType | null => { + if (typeof value === 'string') { + return 'string'; + } + if (typeof value === 'number') { + return Number.isInteger(value) ? 'integer' : 'number'; + } + if (typeof value === 'boolean') { + return 'boolean'; + } + if (Array.isArray(value)) { + return 'array'; + } + if (value === null) { + return 'null'; + } + if (typeof value === 'object') { + return 'object'; + } + + return null; +}; + +const getSchemaTypesAsArray = (schema: JsonSchema): string[] => { + if (typeof schema !== 'object') { + return [ + 'array', + 'boolean', + 'integer', + 'null', + 'number', + 'object', + '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 ['array', 'boolean', 'integer', 'null', 'number', 'object', 'string']; +}; + +const createMixedRenderInfos = ( + parentSchema: JsonSchema, + schema: JsonSchema, + rootSchema: JsonSchema, + control: ControlElement, + path: string, + uischemas: JsonFormsUISchemaRegistryEntry[] +): SchemaRenderInfo[] => { + const resolvedSchemas: JsonSchema[] = []; + schema = resolveSchema(schema, rootSchema); + + if (typeof schema === 'object' && typeof schema.type === 'string') { + resolvedSchemas.push(schema); + } else { + const types = getSchemaTypesAsArray(schema); + + types.forEach((type) => { + resolvedSchemas.push({ + ...(typeof schema === 'object' ? schema : {}), + type, + default: + typeof schema === 'object' && + schema.default !== undefined && + type === getJsonDataType(schema.default) + ? schema.default + : undefined, + }); + }); + } + + return resolvedSchemas + .map((resolvedSchema) => { + if ( + typeof resolvedSchema === 'object' && + resolvedSchema.type === 'array' + ) { + resolvedSchema.items = resolvedSchema.items ?? {}; + resolvedSchema.items = resolveSchema( + resolvedSchema.items as JsonSchema, + rootSchema + ); + + if ((resolvedSchema.items as any) === true) { + resolvedSchema.items = { + type: [ + 'array', + 'boolean', + 'integer', + 'null', + 'number', + 'object', + 'string', + ], + }; + } else if ( + typeof (resolvedSchema.items as JsonSchema7).type !== 'string' && + !Array.isArray((resolvedSchema.items as JsonSchema7).type) + ) { + (resolvedSchema.items as JsonSchema7).type = [ + 'array', + 'boolean', + 'integer', + 'null', + 'number', + 'object', + 'string', + ]; + } + } + + let cleanedSchema = cleanSchema(cloneDeep(resolvedSchema)); + const schemaType = + typeof cleanedSchema === 'object' ? cleanedSchema.type : undefined; + const detailsForSchema = control.options + ? control.options[`${schemaType}-detail`] + : undefined; + const schemaControl = detailsForSchema + ? { + ...control, + options: { ...control.options, detail: detailsForSchema }, + } + : control; + + if ( + typeof cleanedSchema === 'object' && + 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), schema)) { + 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: `${schemaType}`, + }; + }) + .filter((info) => info.uischema) + .map((info, index) => ({ ...info, index })); +}; + +const findPropertySchema = ( + parentSchema: JsonSchema, + propName: string, + rootSchema: JsonSchema +): JsonSchema | undefined => { + if (typeof parentSchema !== 'object') { + return undefined; + } + + if (parentSchema.properties?.[propName]) { + return resolveSchema(parentSchema.properties[propName], rootSchema); + } + + if (parentSchema.patternProperties) { + const matchedPattern = Object.keys(parentSchema.patternProperties).find( + (pattern) => new RegExp(pattern).test(propName) + ); + + if (matchedPattern) { + return resolveSchema( + parentSchema.patternProperties[matchedPattern], + rootSchema + ); + } + } + + if (typeof parentSchema.additionalProperties === 'object') { + return resolveSchema(parentSchema.additionalProperties, rootSchema); + } + + if (parentSchema.additionalProperties === true) { + return { additionalProperties: true }; + } + + return undefined; +}; + +const getArrayItemSchema = ( + parentSchema: JsonSchema, + index: number, + rootSchema: JsonSchema +): JsonSchema | undefined => { + if (typeof parentSchema !== 'object' || !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({ ...(typeof schema === 'object' ? schema : {}), type: 'object' }) + ) as JsonSchema7; + objectSchema.additionalProperties = + objectSchema.additionalProperties !== false + ? objectSchema.additionalProperties ?? true + : false; + return objectSchema; +}; + +const prepareArraySchema = ( + schema: JsonSchema, + rootSchema: JsonSchema +): JsonSchema => { + const arraySchema = cleanSchema( + cloneDeep({ ...(typeof schema === 'object' ? schema : {}), type: 'array' }) + ) as JsonSchema7; + arraySchema.items = arraySchema.items ?? {}; + arraySchema.items = resolveSchema( + arraySchema.items as JsonSchema, + rootSchema + ) as JsonSchema7 | JsonSchema7[]; + + if ((arraySchema.items as any) === true) { + arraySchema.items = { + type: [ + 'array', + 'boolean', + 'integer', + 'null', + 'number', + 'object', + 'string', + ], + }; + } else if ( + typeof (arraySchema.items as JsonSchema7).type !== 'string' && + !Array.isArray((arraySchema.items as JsonSchema7).type) + ) { + (arraySchema.items as JsonSchema7).type = [ + 'array', + 'boolean', + 'integer', + 'null', + 'number', + 'object', + 'string', + ]; + } + + return arraySchema; +}; + +const createFallbackChildSchema = (title: string): JsonSchema => ({ + type: ['array', 'boolean', 'integer', 'null', 'number', 'object', 'string'], + title, +}); + +const getSchemaDefaultType = (schema: JsonSchema): JsonDataType => { + const schemaTypes = getSchemaTypesAsArray(schema); + const firstType = + schemaTypes.find((type) => type !== 'null') ?? schemaTypes[0]; + return (firstType ?? 'object') as JsonDataType; +}; + +const schemaSupportsInputType = ( + schemaType: JsonSchema7['type'] | undefined, + dataType: JsonDataType | null +): boolean => { + if (!dataType || typeof schemaType !== 'string') { + return false; + } + + return ( + schemaType === dataType || + (schemaType === 'number' && dataType === 'integer') + ); +}; + +const prepareChildSchema = ( + childType: JsonDataType, + currentSchema: JsonSchema, + key: string, + index: number | null, + rootSchema: JsonSchema +): JsonSchema => { + let childSchema: JsonSchema | undefined; + + if (index !== null) { + childSchema = getArrayItemSchema(currentSchema, index, rootSchema); + childSchema = childSchema + ? { ...(childSchema as JsonSchema7), title: `Item ${index}` } + : { + type: [ + 'array', + 'boolean', + 'integer', + 'null', + 'number', + 'object', + 'string', + ], + title: `Item ${index}`, + }; + } else { + childSchema = findPropertySchema(currentSchema, key, rootSchema); + childSchema = childSchema + ? { ...(childSchema as JsonSchema7), title: key } + : createFallbackChildSchema(key); + } + + if ( + childType !== 'object' && + childType !== 'array' && + typeof childSchema === 'object' && + (!childSchema.type || (childSchema.type as any) === true) + ) { + childSchema.type = [ + 'array', + 'boolean', + 'integer', + 'null', + 'number', + 'object', + 'string', + ]; + } + + if (childType === 'object') { + return prepareObjectSchema(childSchema); + } + + if (childType === 'array') { + return prepareArraySchema(childSchema, rootSchema); + } + + return childSchema; +}; + +const createTreeNodeControl = ( + schema: JsonSchema, + path: string, + label: string, + enabled: boolean, + readonly: boolean | undefined, + nodeType: JsonDataType, + controlCache: Map +): TreeNodeControl => { + const cacheKey = `${path}\u0000${nodeType}`; + const cached = controlCache.get(cacheKey); + if (cached) { + cached.label = label; + cached.enabled = enabled; + cached.readonly = readonly; + return cached; + } + + const control = { + id: path, + schema, + uischema: createControlElement('#'), + path, + label, + required: false, + enabled, + readonly, + }; + controlCache.set(cacheKey, control); + return control; +}; + +const withoutEmptyChildren = (node: MixedTreeNode): MixedTreeNode => { + const children = node.children?.map(withoutEmptyChildren) ?? []; + if (children.length === 0) { + const { children: _children, ...rest } = node; + return rest; + } + + return { + ...node, + children, + }; +}; + +const getDisplayTitle = (label: string, type: JsonDataType): string => { + if (label) { + return label; + } + return type === 'array' ? '[]' : '{}'; +}; + +const isDynamicProperty = (parentSchema: JsonSchema, key: string): boolean => + typeof parentSchema !== 'object' || !parentSchema.properties?.[key]; + +const buildTreeFromData = ( + data: any, + schema: JsonSchema, + rootSchema: JsonSchema, + path: string, + label: string, + enabled: boolean, + readonly: boolean | undefined, + showPrimitives: boolean, + controlCache: Map +): 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 + ) => { + 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, + currentLabel, + enabled, + readonly, + type, + controlCache + ), + children: [], + }; + children.push(node); + + Object.keys(value).forEach((key) => { + const childValue = value[key]; + const childPath = compose(currentPath, key); + const rawChildType = getJsonDataType(childValue); + const initialChildSchema = + findPropertySchema(currentSchema, key, rootSchema) ?? + createFallbackChildSchema(key); + const childType = + rawChildType ?? getSchemaDefaultType(initialChildSchema); + const childSchema = prepareChildSchema( + childType, + currentSchema, + key, + null, + rootSchema + ); + + if (childType === 'object' || childType === 'array') { + traverse( + childValue ?? (childType === 'array' ? [] : {}), + childPath, + key, + childSchema, + node.children, + isDynamicProperty(currentSchema, key), + true + ); + } else if (showPrimitives) { + node.children.push({ + nodeId: toTreeNodeId(childPath), + title: key, + jsonType: childType, + label: key, + canRename: isDynamicProperty(currentSchema, key), + canDelete: true, + control: createTreeNodeControl( + childSchema, + childPath, + key, + enabled, + readonly, + childType, + controlCache + ), + children: [], + }); + } + }); + } 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, + currentLabel, + enabled, + readonly, + type, + controlCache + ), + children: [], + }; + children.push(node); + + value.forEach((childValue: any, index: number) => { + const childType = getJsonDataType(childValue); + const childPath = compose(currentPath, `${index}`); + const childSchema = prepareChildSchema( + childType ?? 'object', + currentSchema, + '', + index, + rootSchema + ); + const resolvedChildType = + childType ?? getSchemaDefaultType(childSchema); + const childLabel = `Item ${index}`; + + 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, + childLabel, + enabled, + readonly, + resolvedChildType, + controlCache + ), + children: [], + }); + } + }); + } + }; + + traverse(data, path, label, resolveSchema(schema, rootSchema), nodes); + + return nodes.map(withoutEmptyChildren); +}; + +const flattenTree = (nodes: MixedTreeNode[]): MixedTreeNode[] => + nodes.flatMap((node) => [node, ...flattenTree(node.children ?? [])]); + +const getTreeStructureSignature = ( + value: any, + showPrimitives: boolean +): string => { + const type = getJsonDataType(value); + + if (type === 'object') { + return `o{${Object.keys(value) + .map((key) => { + const childType = getJsonDataType(value[key]); + if (childType === 'object' || childType === 'array') { + return `${key}:${getTreeStructureSignature( + value[key], + showPrimitives + )}`; + } + return showPrimitives ? `${key}:p` : ''; + }) + .filter(Boolean) + .join('|')}}`; + } + + if (type === 'array') { + return `a[${value + .map((childValue: any) => { + const childType = getJsonDataType(childValue); + if (childType === 'object' || childType === 'array') { + return getTreeStructureSignature(childValue, showPrimitives); + } + return showPrimitives ? 'p' : ''; + }) + .join('|')}]`; + } + + return showPrimitives ? 'p' : ''; +}; + +const filterTreeNodes = ( + nodes: MixedTreeNode[], + search: string +): MixedTreeNode[] => { + const normalizedSearch = search.trim().toLocaleLowerCase(); + if (!normalizedSearch) { + return nodes; + } + + return nodes.flatMap((node) => { + const children = filterTreeNodes(node.children ?? [], normalizedSearch); + const matchesSearch = + node.title.toLocaleLowerCase().includes(normalizedSearch) || + node.label.toLocaleLowerCase().includes(normalizedSearch); + + if (!matchesSearch && children.length === 0) { + return []; + } + + return [ + { + ...node, + children, + }, + ]; + }); +}; + +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; +}; + +const getTypeIcon = (type: JsonDataType | undefined) => { + switch (type) { + case 'array': + return ; + case 'object': + return ; + case 'boolean': + return ; + case 'integer': + case 'number': + return ; + case 'string': + return ; + case 'null': + default: + return ; + } +}; + +const getRelativePath = (rootPath: string, nodePath: string): string | null => { + if (nodePath === rootPath) { + return null; + } + return rootPath && nodePath.startsWith(`${rootPath}.`) + ? nodePath.slice(rootPath.length + 1) + : nodePath; +}; + +const getParentPath = (rootPath: string, nodePath: string): string => { + const lastDot = nodePath.lastIndexOf('.'); + return lastDot > 0 ? nodePath.substring(0, lastDot) : rootPath; +}; + +const TypeSelector = ({ + id, + label, + required, + enabled, + readonly, + errors, + selectedIndex, + infos, + onChange, +}: { + id: string; + label: string; + required?: boolean; + enabled: boolean; + readonly?: boolean; + errors?: string; + selectedIndex: number | undefined; + infos: SchemaRenderInfo[]; + onChange: (index: number | undefined) => void; +}) => { + const variant = useInputVariant(); + const hasErrors = Boolean(errors); + + return ( + + {label} + + {hasErrors && ( + + {errors} + + )} + + ); +}; + +export const MaterialMixedRenderer = ({ + cells, + data, + enabled, + errors, + handleChange, + id, + label, + path, + readonly, + renderers, + required, + rootSchema, + schema: controlSchema, + uischema, + visible, +}: ControlProps) => { + const jsonforms = useJsonForms(); + const navigationContext = useContext(MixedNavigationContext); + const isRoot = !navigationContext; + const [valueType, setValueType] = useState( + getJsonDataType(data) + ); + const [selectedIndex, setSelectedIndex] = useState(); + const [activeNodeId, setActiveNodeId] = useState(toTreeNodeId(path)); + const [openedNodes, setOpenedNodes] = useState([]); + const [treeSearch, setTreeSearch] = useState(''); + const [showPrimitivesInTree, setShowPrimitivesInTree] = useState(false); + const [renamingNodeId, setRenamingNodeId] = useState(null); + const [renameValue, setRenameValue] = useState(''); + const [renameError, setRenameError] = useState(null); + const [treeWidth, setTreeWidth] = useState(320); + const [treeExpanded, setTreeExpanded] = useState(true); + const [draggingSplitter, setDraggingSplitter] = useState(false); + const latestTreeData = useRef(data); + const treeControlCache = useRef(new Map()); + const inputVariant = useInputVariant(); + + const uischemas = jsonforms.uischemas ?? EMPTY_UISCHEMAS; + const mixedRenderInfos = useMemo( + () => + createMixedRenderInfos( + controlSchema, + controlSchema, + rootSchema, + uischema, + path, + uischemas + ), + [controlSchema, rootSchema, uischema, path, uischemas] + ); + const matchingSchema = useMemo(() => { + const exact = mixedRenderInfos.find( + (entry) => entry.resolvedSchema.type === valueType + ); + return ( + exact ?? + mixedRenderInfos.find( + (entry) => + entry.resolvedSchema.type === 'number' && valueType === 'integer' + ) + ); + }, [mixedRenderInfos, valueType]); + const activeInfo = + selectedIndex !== undefined ? mixedRenderInfos[selectedIndex] : undefined; + const activeType = activeInfo?.resolvedSchema?.type; + const inlineDetailInfo = + activeInfo?.schema && activeInfo?.uischema && activeType !== 'null' + ? activeInfo + : undefined; + const showTreeView = + isRoot && (valueType === 'object' || valueType === 'array'); + const isNestedComplexType = + !isRoot && (valueType === 'object' || valueType === 'array'); + latestTreeData.current = data; + + useEffect(() => { + setValueType(getJsonDataType(data)); + }, [data]); + + useEffect(() => { + const currentlySelected = + selectedIndex !== undefined ? mixedRenderInfos[selectedIndex] : undefined; + if ( + currentlySelected && + schemaSupportsInputType(currentlySelected.resolvedSchema.type, valueType) + ) { + return; + } + setSelectedIndex(matchingSchema?.index); + }, [matchingSchema?.index, mixedRenderInfos, selectedIndex, valueType]); + + const treeStructureSignature = useMemo( + () => + showTreeView ? getTreeStructureSignature(data, showPrimitivesInTree) : '', + [data, showPrimitivesInTree, showTreeView] + ); + const treeNodes = useMemo( + () => + showTreeView + ? buildTreeFromData( + latestTreeData.current, + activeInfo?.resolvedSchema ?? controlSchema, + rootSchema, + path, + label, + enabled, + readonly, + showPrimitivesInTree, + treeControlCache.current + ) + : [], + [ + activeInfo?.resolvedSchema, + controlSchema, + enabled, + label, + path, + readonly, + rootSchema, + showPrimitivesInTree, + showTreeView, + treeStructureSignature, + ] + ); + const selectedNode = useMemo( + () => findNodeById(treeNodes, activeNodeId), + [treeNodes, activeNodeId] + ); + const filteredTreeNodes = useMemo( + () => filterTreeNodes(treeNodes, treeSearch), + [treeNodes, treeSearch] + ); + const expandedTreeItems = useMemo( + () => + treeSearch.trim() + ? flattenTree(filteredTreeNodes).map((node) => node.nodeId) + : openedNodes, + [filteredTreeNodes, openedNodes, treeSearch] + ); + + useEffect(() => { + const allNodeIds = flattenTree(treeNodes).map((node) => node.nodeId); + const allNodeIdSet = new Set(allNodeIds); + const rootNodeId = toTreeNodeId(path); + if (!allNodeIdSet.has(activeNodeId)) { + setActiveNodeId(rootNodeId); + } + setOpenedNodes((current) => + Array.from(new Set([rootNodeId, ...current])).filter((nodeId) => + allNodeIdSet.has(nodeId) + ) + ); + }, [treeNodes, path, activeNodeId]); + + useEffect(() => { + if (!draggingSplitter) { + return undefined; + } + + const onMouseMove = (event: MouseEvent) => { + setTreeWidth(Math.min(640, Math.max(220, event.clientX - 32))); + }; + const onMouseUp = () => setDraggingSplitter(false); + + window.addEventListener('mousemove', onMouseMove); + window.addEventListener('mouseup', onMouseUp); + + return () => { + window.removeEventListener('mousemove', onMouseMove); + window.removeEventListener('mouseup', onMouseUp); + }; + }, [draggingSplitter]); + + const getPathAncestorNodeIds = useCallback( + (targetPath: string): string[] => { + const segments = targetPath.split('.').filter(Boolean); + const result = [toTreeNodeId(path)]; + let currentPath = path; + + segments.slice(0, -1).forEach((segment) => { + currentPath = compose(currentPath, segment); + result.push(toTreeNodeId(currentPath)); + }); + + return result; + }, + [path] + ); + + const selectPath = useCallback( + (targetPath: string) => { + setActiveNodeId(toTreeNodeId(targetPath)); + setOpenedNodes((current) => + Array.from(new Set([...current, ...getPathAncestorNodeIds(targetPath)])) + ); + }, + [getPathAncestorNodeIds] + ); + + const navigationValue = useMemo(() => ({ selectPath }), [selectPath]); + + const getParentSchema = useCallback( + (parentPath: string): JsonSchema | undefined => { + const parentRelativePath = getRelativePath(path, parentPath); + if (parentRelativePath === null) { + return activeInfo?.resolvedSchema ?? controlSchema; + } + + const segments = parentRelativePath.split('.'); + let currentSchema: JsonSchema = + activeInfo?.resolvedSchema ?? controlSchema; + + for (const segment of segments) { + currentSchema = resolveSchema(currentSchema, rootSchema); + if (typeof currentSchema !== 'object') { + return {}; + } + if (currentSchema.type === 'array') { + currentSchema = (currentSchema.items as JsonSchema) ?? {}; + } else { + currentSchema = + currentSchema.properties?.[segment] ?? + findPropertySchema(currentSchema, segment, rootSchema) ?? + {}; + } + } + + return currentSchema; + }, + [activeInfo?.resolvedSchema, controlSchema, path, rootSchema] + ); + + const startRename = useCallback((node: MixedTreeNode) => { + if (!node.canRename) { + return; + } + setRenamingNodeId(node.nodeId); + setRenameValue(node.label); + setRenameError(null); + }, []); + + const cancelRename = useCallback(() => { + setRenamingNodeId(null); + setRenameValue(''); + setRenameError(null); + }, []); + + const commitRename = useCallback( + (node: MixedTreeNode) => { + if (renamingNodeId !== node.nodeId) { + return; + } + + const trimmed = renameValue.trim(); + if (!trimmed || trimmed === node.label) { + cancelRename(); + return; + } + + const parentPath = getParentPath(path, node.control.path); + const parentRelativePath = getRelativePath(path, parentPath); + const parentData = + parentRelativePath === null ? data : get(data, parentRelativePath); + + if ( + typeof parentData !== 'object' || + parentData === null || + Array.isArray(parentData) + ) { + cancelRename(); + return; + } + + if (trimmed in parentData) { + setRenameError(`Property "${trimmed}" already exists`); + return; + } + + let parentSchema = getParentSchema(parentPath); + if (parentSchema) { + parentSchema = resolveSchema(parentSchema, rootSchema); + } + + if (typeof parentSchema === 'object' && parentSchema.patternProperties) { + const patterns = Object.keys(parentSchema.patternProperties); + const hadMatchingPattern = patterns.some((pattern) => + new RegExp(pattern).test(node.label) + ); + const hasMatchingPattern = patterns.some((pattern) => + new RegExp(pattern).test(trimmed) + ); + if (hadMatchingPattern && !hasMatchingPattern) { + setRenameError( + `Property name must match pattern: ${patterns.join(', ')}` + ); + return; + } + } + + const propertyNames = (parentSchema as JsonSchema7)?.propertyNames as + | JsonSchema7 + | undefined; + if (propertyNames?.pattern) { + const pattern = new RegExp(propertyNames.pattern); + if (!pattern.test(trimmed)) { + setRenameError( + `Property name must match pattern: ${propertyNames.pattern}` + ); + return; + } + } + + const updatedData = Object.fromEntries( + Object.entries(parentData).map(([key, value]) => [ + key === node.label ? trimmed : key, + value, + ]) + ); + handleChange(parentPath, updatedData); + + const newPath = compose(parentPath, trimmed); + selectPath(newPath); + cancelRename(); + }, + [ + cancelRename, + data, + getParentSchema, + handleChange, + path, + renameValue, + renamingNodeId, + rootSchema, + selectPath, + ] + ); + + const deleteNode = useCallback( + (node: MixedTreeNode) => { + if (!node.canDelete) { + return; + } + + const parentPath = getParentPath(path, node.control.path); + const parentRelativePath = getRelativePath(path, parentPath); + const parentData = + parentRelativePath === null ? data : get(data, parentRelativePath); + const key = node.control.path.slice( + parentPath.length ? parentPath.length + 1 : 0 + ); + + if (Array.isArray(parentData)) { + const index = Number(key); + if (!Number.isInteger(index)) { + return; + } + const updatedData = [...parentData]; + updatedData.splice(index, 1); + handleChange(parentPath, updatedData); + } else if (typeof parentData === 'object' && parentData !== null) { + const updatedData = { ...parentData }; + delete updatedData[key]; + handleChange(parentPath, updatedData); + } + + if ( + activeNodeId === node.nodeId || + activeNodeId.startsWith(`${node.nodeId}.`) + ) { + selectPath(parentPath); + } + }, + [activeNodeId, data, handleChange, path, selectPath] + ); + + const handleSelectChange = useCallback( + (newIndex: number | undefined) => { + const newData = + newIndex !== undefined + ? createDefaultValue( + mixedRenderInfos[newIndex].resolvedSchema, + rootSchema + ) + : undefined; + + handleChange(path, newData); + setSelectedIndex(newIndex); + const type = + newIndex !== undefined + ? mixedRenderInfos[newIndex]?.resolvedSchema?.type + : null; + setValueType(type as JsonDataType | null); + setActiveNodeId(toTreeNodeId(path)); + }, + [handleChange, mixedRenderInfos, path, rootSchema] + ); + + if (!visible) { + return null; + } + + const typeSelector = ( + + ); + + const renderTreeNode = (node: MixedTreeNode): React.ReactNode => ( + + + {getTypeIcon(node.jsonType)} + + {renamingNodeId === node.nodeId ? ( + event.stopPropagation()} + onBlur={() => commitRename(node)} + onChange={(event) => setRenameValue(event.target.value)} + onKeyDown={(event) => { + event.stopPropagation(); + if (event.key === 'Enter') { + commitRename(node); + } else if (event.key === 'Escape') { + cancelRename(); + } + }} + /> + ) : ( + + {node.title} + + )} + + {node.control.path === path ? ( + + { + event.stopPropagation(); + setShowPrimitivesInTree((current) => !current); + }} + > + {showPrimitivesInTree ? ( + + ) : ( + + )} + + + ) : enabled ? ( + <> + {node.canRename ? ( + + { + event.stopPropagation(); + startRename(node); + }} + > + + + + ) : null} + {node.canDelete ? ( + + { + event.stopPropagation(); + deleteNode(node); + }} + > + + + + ) : null} + + ) : null} + + + } + sx={{ + '& .MuiTreeItem-content': { + minHeight: 36, + pr: 0.5, + }, + '& .MuiTreeItem-label': { + minWidth: 0, + }, + }} + > + {node.children?.map((child) => renderTreeNode(child))} + + ); + + if (showTreeView) { + return ( + + setTreeExpanded(expanded)} + sx={{ + overflow: 'hidden', + width: '100%', + '&:before': { + display: 'none', + }, + }} + > + } + sx={{ + px: 2, + py: 0, + '& .MuiAccordionSummary-content': { + minWidth: 0, + }, + }} + > + + event.stopPropagation()} + onFocus={(event) => event.stopPropagation()} + > + {typeSelector} + + + {label} + + + + + + + setTreeSearch(event.target.value)} + InputProps={{ + startAdornment: ( + + + + ), + }} + /> + { + if (!treeSearch.trim()) { + setOpenedNodes(itemIds); + } + }} + onSelectedItemsChange={(_, itemId) => { + if (typeof itemId === 'string') { + setActiveNodeId(itemId); + } + }} + sx={{ + mt: 1, + maxHeight: 'calc(100vh - 300px)', + overflow: 'auto', + }} + > + {filteredTreeNodes.map((node) => renderTreeNode(node))} + + + setDraggingSplitter(true)} + sx={{ + bgcolor: draggingSplitter ? 'primary.main' : 'divider', + cursor: 'col-resize', + width: 6, + }} + /> + + {selectedNode ? ( + + ) : null} + + + + + + ); + } + + if (isNestedComplexType) { + return ( + + {typeSelector} + + + navigationContext?.selectPath(path)} + size='small' + sx={{ mt: 0.75 }} + > + + + + + + ); + } + + return ( + + {typeSelector} + + {inlineDetailInfo ? ( + + + + ) : null} + + + ); +}; + +export const isMixedSchema = ( + uischema: UISchemaElement & Scopable, + schema: JsonSchema, + context: TesterContext +) => { + if (schema && typeof schema === 'boolean') { + return true; + } + + if (!schema || typeof schema !== 'object') { + return false; + } + + if (Array.isArray(schema.type)) { + return true; + } + + if (schema.type === 'object') { + const schemaPath = uischema.scope; + if (schemaPath && !isEmpty(schemaPath)) { + const currentDataSchema = resolveSchemaCore( + schema, + schemaPath, + context?.rootSchema + ); + if (currentDataSchema === undefined) { + return false; + } + if (Array.isArray(currentDataSchema.type)) { + return true; + } + } + } + + return false; +}; + +const isDefaultGenUiSchema = (uischema: UISchemaElement): boolean => { + const elements = (uischema as any)?.elements; + return ( + (uischema.type === 'VerticalLayout' || uischema.type === 'Group') && + Array.isArray(elements) && + elements.length === 1 && + elements[0].scope === '#' && + elements[0].type === 'Control' + ); +}; + +export const isMixedControl = ( + uischema: UISchemaElement, + schema: JsonSchema, + context: TesterContext +) => + isMixedSchema(uischema as UISchemaElement & Scopable, schema, context) && + (isControl(uischema) || isDefaultGenUiSchema(uischema)); + +export const materialMixedControlTester: RankedTester = rankWith( + 20, + isMixedControl +); + +export default withJsonFormsControlProps(MaterialMixedRenderer); diff --git a/packages/material-renderers/src/complex/MaterialObjectRenderer.tsx b/packages/material-renderers/src/complex/MaterialObjectRenderer.tsx index aeb170e01d..de0765f2ee 100644 --- a/packages/material-renderers/src/complex/MaterialObjectRenderer.tsx +++ b/packages/material-renderers/src/complex/MaterialObjectRenderer.tsx @@ -24,28 +24,55 @@ */ import isEmpty from 'lodash/isEmpty'; import { + ControlProps, findUISchema, Generate, + GroupLayout, isObjectControl, RankedTester, rankWith, - StatePropsOfControlWithDetail, + UISchemaElement, } from '@jsonforms/core'; -import { JsonFormsDispatch, withJsonFormsDetailProps } from '@jsonforms/react'; +import { + JsonFormsDispatch, + useJsonForms, + withJsonFormsControlProps, +} from '@jsonforms/react'; import React, { useMemo } from 'react'; +import { MaterialAdditionalProperties } from './MaterialAdditionalProperties'; +import { Card, CardContent, CardHeader } from '@mui/material'; + +const objectCardStyle: { [x: string]: any } = { marginBottom: '10px' }; + +const withoutGroupFrame = (uischema: UISchemaElement): UISchemaElement => { + if (uischema.type !== 'Group') { + return uischema; + } + + const { label: _label, ...layout } = uischema as GroupLayout; + return { + ...layout, + type: 'VerticalLayout', + }; +}; export const MaterialObjectRenderer = ({ - renderers, cells, - uischemas, - schema, + config, + data, + enabled, + handleChange, label, path, - visible, - enabled, + readonly, + renderers, + schema, uischema, rootSchema, -}: StatePropsOfControlWithDetail) => { + visible, +}: ControlProps) => { + const jsonforms = useJsonForms(); + const uischemas = jsonforms.uischemas ?? []; const detailUiSchema = useMemo( () => findUISchema( @@ -54,32 +81,64 @@ export const MaterialObjectRenderer = ({ uischema.scope, path, () => - isEmpty(path) - ? Generate.uiSchema(schema, 'VerticalLayout', undefined, rootSchema) - : { - ...Generate.uiSchema(schema, 'Group', undefined, rootSchema), - label, - }, + Generate.uiSchema(schema, 'VerticalLayout', undefined, rootSchema), uischema, rootSchema ), - [uischemas, schema, uischema.scope, path, label, uischema, rootSchema] + [uischemas, schema, uischema.scope, path, uischema, rootSchema] + ); + const dispatchUiSchema = useMemo( + () => (isEmpty(path) ? detailUiSchema : withoutGroupFrame(detailUiSchema)), + [detailUiSchema, path] ); + const objectLabel = + detailUiSchema.type === 'Group' + ? (detailUiSchema as GroupLayout).label ?? label + : label; if (!visible) { return null; } + const content = ( + <> + + + + ); + + if (isEmpty(path)) { + return content; + } + return ( - + + {!isEmpty(objectLabel) && } + {content} + ); }; @@ -88,4 +147,4 @@ export const materialObjectControlTester: RankedTester = rankWith( isObjectControl ); -export default withJsonFormsDetailProps(MaterialObjectRenderer); +export default withJsonFormsControlProps(MaterialObjectRenderer); diff --git a/packages/material-renderers/src/complex/index.ts b/packages/material-renderers/src/complex/index.ts index 8331cb7812..2420b0c688 100644 --- a/packages/material-renderers/src/complex/index.ts +++ b/packages/material-renderers/src/complex/index.ts @@ -25,6 +25,7 @@ import MaterialAllOfRenderer, { materialAllOfControlTester, } from './MaterialAllOfRenderer'; +import { MaterialAdditionalProperties } from './MaterialAdditionalProperties'; import MaterialAnyOfRenderer, { materialAnyOfControlTester, } from './MaterialAnyOfRenderer'; @@ -34,6 +35,9 @@ import MaterialArrayControlRenderer, { import MaterialEnumArrayRenderer, { materialEnumArrayRendererTester, } from './MaterialEnumArrayRenderer'; +import MaterialMixedRenderer, { + materialMixedControlTester, +} from './MaterialMixedRenderer'; import MaterialObjectRenderer, { materialObjectControlTester, } from './MaterialObjectRenderer'; @@ -42,6 +46,7 @@ import MaterialOneOfRenderer, { } from './MaterialOneOfRenderer'; export { + MaterialAdditionalProperties, materialAllOfControlTester, MaterialAllOfRenderer, materialAnyOfControlTester, @@ -50,6 +55,8 @@ export { MaterialArrayControlRenderer, materialEnumArrayRendererTester, MaterialEnumArrayRenderer, + materialMixedControlTester, + MaterialMixedRenderer, materialObjectControlTester, MaterialObjectRenderer, materialOneOfControlTester, diff --git a/packages/material-renderers/src/complex/unwrapped.ts b/packages/material-renderers/src/complex/unwrapped.ts index 63b5bb6d97..c4c37bdd1b 100644 --- a/packages/material-renderers/src/complex/unwrapped.ts +++ b/packages/material-renderers/src/complex/unwrapped.ts @@ -26,6 +26,7 @@ import { MaterialAllOfRenderer } from './MaterialAllOfRenderer'; import { MaterialAnyOfRenderer } from './MaterialAnyOfRenderer'; import { MaterialArrayControlRenderer } from './MaterialArrayControlRenderer'; import { MaterialEnumArrayRenderer } from './MaterialEnumArrayRenderer'; +import { MaterialMixedRenderer } from './MaterialMixedRenderer'; import { MaterialObjectRenderer } from './MaterialObjectRenderer'; import { MaterialOneOfRenderer } from './MaterialOneOfRenderer'; @@ -34,6 +35,7 @@ export const UnwrappedComplex = { MaterialAnyOfRenderer, MaterialArrayControlRenderer, MaterialEnumArrayRenderer, + MaterialMixedRenderer, MaterialObjectRenderer, MaterialOneOfRenderer, }; diff --git a/packages/material-renderers/src/index.ts b/packages/material-renderers/src/index.ts index cf456e9063..364b464bdb 100644 --- a/packages/material-renderers/src/index.ts +++ b/packages/material-renderers/src/index.ts @@ -33,6 +33,8 @@ import { MaterialAnyOfRenderer, MaterialArrayControlRenderer, materialArrayControlTester, + MaterialMixedRenderer, + materialMixedControlTester, materialObjectControlTester, MaterialObjectRenderer, materialOneOfControlTester, @@ -126,6 +128,7 @@ export * from './util'; export const materialRenderers: JsonFormsRendererRegistryEntry[] = [ // controls + { tester: materialMixedControlTester, renderer: MaterialMixedRenderer }, { tester: materialArrayControlTester, renderer: MaterialArrayControlRenderer, 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 73a7cdec93..c9704c21f8 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.2.0", "vue": "^3.5.0", "vuetify": "^3.11.0" }, @@ -107,7 +108,7 @@ "resize-observer-polyfill": "^1.5.1", "rimraf": "^6.1.0", "rollup-plugin-postcss": "^4.0.2", - "splitpanes": "^3.1.5", + "splitpanes": "^3.2.0", "typedoc": "~0.25.3", "typescript": "~5.8.3", "vite": "^5.4.21", diff --git a/packages/vue-vuetify/src/complex/MixedRenderer.vue b/packages/vue-vuetify/src/complex/MixedRenderer.vue index 5f572a7f17..4b426d8fe3 100644 --- a/packages/vue-vuetify/src/complex/MixedRenderer.vue +++ b/packages/vue-vuetify/src/complex/MixedRenderer.vue @@ -1,13 +1,13 @@