diff --git a/packages/examples/src/examples/additional-properties.ts b/packages/examples/src/examples/additional-properties.ts index 01fc152a7..673686b9a 100644 --- a/packages/examples/src/examples/additional-properties.ts +++ b/packages/examples/src/examples/additional-properties.ts @@ -31,6 +31,17 @@ export const schema = { propertiesString: { type: 'string', }, + propertiesArrayOfValuesByKey: { + type: 'object', + title: 'Array of Values by Key', + additionalProperties: { + type: 'array', + items: { + type: 'string', + }, + title: 'Item', + }, + }, }, propertyNames: { minLength: 2, @@ -91,6 +102,16 @@ export const schema = { type: 'boolean', }, }, + '^arrayOfValuesByKey$': { + type: 'object', + additionalProperties: { + type: 'array', + items: { + type: 'string', + }, + title: 'Item', + }, + }, }, additionalProperties: { type: 'string', @@ -106,6 +127,10 @@ export const uischema = { const data = { propertiesString: 'data', + propertiesArrayOfValuesByKey: { + keyOne: ['value1', 'value2'], + keyTwo: ['value3'], + }, string: 'string value', number: 10.2, integer: 11, @@ -118,6 +143,10 @@ const data = { integerArray: [33], objectArray: [{ prop1: 'prop1 val' }, {}], booleanArray: [false, true], + arrayOfValuesByKey: { + keyOne: ['value1', 'value2'], + keyTwo: ['value3'], + }, }; registerExamples([ diff --git a/packages/material-renderers/src/additional/MaterialAdditionalPropertiesRenderer.tsx b/packages/material-renderers/src/additional/MaterialAdditionalPropertiesRenderer.tsx new file mode 100644 index 000000000..f721c3263 --- /dev/null +++ b/packages/material-renderers/src/additional/MaterialAdditionalPropertiesRenderer.tsx @@ -0,0 +1,475 @@ +/* + The MIT License + + Copyright (c) 2017-2019 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 { + composePaths, + createControlElement, + createDefaultValue, + Generate, + JsonSchema, + JsonSchema7, + Resolve, + UISchemaElement, +} from '@jsonforms/core'; +import { JsonFormsDispatch } from '@jsonforms/react'; +import AddIcon from '@mui/icons-material/Add'; +import DeleteIcon from '@mui/icons-material/Delete'; +import { + Box, + Card, + CardContent, + Grid, + IconButton, + TextField, + Tooltip, + Typography, +} from '@mui/material'; +import startCase from 'lodash/startCase'; +import React, { useCallback, useMemo, useState } from 'react'; + +interface AdditionalPropertyType { + propertyName: string; + path: string; + schema: JsonSchema | undefined; + uischema: UISchemaElement | undefined; +} + +export interface MaterialAdditionalPropertiesRendererProps { + schema: JsonSchema; + rootSchema: JsonSchema; + path: string; + data: any; + handleChange: (path: string, value: any) => void; + enabled: boolean; + visible: boolean; + renderers: any[]; + cells: any[]; + config?: any; + label?: string; + uischema: UISchemaElement; + containerTitle?: string; +} + +export const MaterialAdditionalPropertiesRenderer = ({ + data, + path, + schema, + rootSchema, + handleChange, + enabled, + visible, + renderers, + cells, + containerTitle, +}: MaterialAdditionalPropertiesRendererProps) => { + const [newPropertyName, setNewPropertyName] = useState(''); + const [newPropertyErrors, setNewPropertyErrors] = useState([]); + + const reservedPropertyNames = useMemo( + () => Object.keys(schema.properties || {}), + [schema.properties] + ); + + const additionalKeys = useMemo( + () => + Object.keys(data || {}).filter( + (k) => !reservedPropertyNames.includes(k) + ), + [data, reservedPropertyNames] + ); + + const toAdditionalPropertyType = useCallback( + ( + propName: string, + parentSchema: JsonSchema, + rootSchemaRef: JsonSchema + ): AdditionalPropertyType => { + let propSchema: JsonSchema | undefined = undefined; + let propUiSchema: UISchemaElement | undefined = undefined; + + // Check pattern properties first + if (parentSchema.patternProperties) { + const matchedPattern = Object.keys(parentSchema.patternProperties).find( + (pattern) => new RegExp(pattern).test(propName) + ); + if (matchedPattern) { + propSchema = parentSchema.patternProperties[matchedPattern]; + } + } + + // Check additional properties + if ( + (!propSchema && + typeof parentSchema.additionalProperties === 'object') || + parentSchema.additionalProperties === true + ) { + propSchema = + parentSchema.additionalProperties === true + ? { additionalProperties: true } + : parentSchema.additionalProperties; + } + + // Resolve $ref if present + if (typeof propSchema?.$ref === 'string') { + propSchema = Resolve.schema(propSchema, propSchema.$ref, rootSchemaRef); + } + + propSchema = propSchema ?? {}; + + // Set default type if not specified + if (propSchema.type === undefined) { + propSchema = { + ...propSchema, + type: [ + 'array', + 'boolean', + 'integer', + 'null', + 'number', + 'object', + 'string', + ] as any, + }; + } + + // Generate UI schema + if (propSchema.type === 'array') { + propUiSchema = Generate.uiSchema( + propSchema, + 'Group', + undefined, + rootSchemaRef + ); + if (propUiSchema.type !== 'Label') { + const titleToUse = propSchema.title ?? startCase(propName); + (propUiSchema as any).label = titleToUse; + } + } else if (propSchema.type === 'object') { + propUiSchema = Generate.uiSchema( + propSchema, + 'Group', + undefined, + rootSchemaRef + ); + if (propUiSchema.type !== 'Label') { + const titleToUse = propSchema.title ?? startCase(propName); + (propUiSchema as any).label = titleToUse; + } + } else { + propUiSchema = createControlElement('#'); + } + + // Set up schema with title (always use property name for objects with additional properties) + 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 ?? {}; + // For arrays, ensure items schema doesn't have a generic title + if (typeof propSchema.items === 'object' && !Array.isArray(propSchema.items) && (propSchema.items as any).title) { + propSchema.items = { + ...propSchema.items, + title: undefined + }; + } + } const result = { + propertyName: propName, + path: composePaths(path, propName), + schema: propSchema, + uischema: propUiSchema, + }; + + return result; + }, + [path] + ); + + const additionalPropertyItems = useMemo( + () => { + const items = additionalKeys.map((propName) => + toAdditionalPropertyType(propName, schema, rootSchema) + ); + console.log('additionalPropertyItems:', { path, additionalKeys, items }); + return items; + }, + [additionalKeys, data, schema, rootSchema, toAdditionalPropertyType, path] + ); + + const validatePropertyName = useCallback( + (propertyName: string): string[] => { + const errors: string[] = []; + + if (!propertyName.trim()) { + errors.push('Property name is required'); + return errors; + } + + // Check if property already exists + if (data && Object.keys(data).includes(propertyName)) { + errors.push(`Property "${propertyName}" already exists`); + } + + // Check for invalid characters (JSONForms path composition uses these) + if ( + propertyName.includes('[') || + propertyName.includes(']') || + propertyName.includes('.') + ) { + errors.push('Property name cannot contain "[", "]", or "."'); + } + + return errors; + }, + [data] + ); + + const handlePropertyNameChange = useCallback( + (event: React.ChangeEvent) => { + const value = event.target.value; + setNewPropertyName(value); + setNewPropertyErrors(validatePropertyName(value)); + }, + [validatePropertyName] + ); + + const addProperty = useCallback(() => { + if (!newPropertyName.trim() || newPropertyErrors.length > 0) { + return; + } + + const additionalProperty = toAdditionalPropertyType( + newPropertyName, + schema, + rootSchema + ); + + if (additionalProperty.schema) { + const updatedData = { ...data }; + updatedData[newPropertyName] = createDefaultValue( + additionalProperty.schema, + rootSchema + ); + handleChange(path, updatedData); + } + + setNewPropertyName(''); + setNewPropertyErrors([]); + }, [ + newPropertyName, + newPropertyErrors, + toAdditionalPropertyType, + schema, + rootSchema, + data, + handleChange, + path, + ]); + + const removeProperty = useCallback( + (propName: string) => { + if (data) { + const updatedData = { ...data }; + delete updatedData[propName]; + handleChange(path, updatedData); + } + }, + [data, handleChange, path] + ); + + const addPropertyDisabled = useMemo(() => { + if (!enabled) return true; + if (!newPropertyName.trim()) return true; + if (newPropertyErrors.length > 0) return true; + + // Check maxProperties constraint + if ( + schema.maxProperties !== undefined && + data && + Object.keys(data).length >= schema.maxProperties + ) { + return true; + } + + return false; + }, [enabled, newPropertyName, newPropertyErrors, schema.maxProperties, data]); + + const removePropertyDisabled = useMemo(() => { + if (!enabled) return true; + + // Check minProperties constraint + if ( + schema.minProperties !== undefined && + data && + Object.keys(data).length <= schema.minProperties + ) { + return true; + } + + return false; + }, [enabled, schema.minProperties, data]); + + const additionalPropertiesTitle = useMemo(() => { + // Use containerTitle prop if provided + if (containerTitle) { + return containerTitle; + } + + // Check if the current property (from path) matches a pattern property + // Extract the property name from the path (e.g., "/arrayOfValuesByKey" -> "arrayOfValuesByKey") + const pathSegments = path.split('/').filter(Boolean); + const currentPropertyName = pathSegments[pathSegments.length - 1]; + + if (currentPropertyName && schema.patternProperties) { + const matchedPattern = Object.keys(schema.patternProperties).find( + (pattern) => new RegExp(pattern).test(currentPropertyName) + ); + if (matchedPattern) { + const patternSchema = schema.patternProperties[matchedPattern]; + if (patternSchema && typeof patternSchema === 'object' && patternSchema.title) { + return patternSchema.title; + } + } + } + + // Fall back to schema.additionalProperties.title + const title = (schema.additionalProperties as JsonSchema7)?.title; + return title || undefined; + }, [containerTitle, schema.patternProperties, schema.additionalProperties, path]); + + if (!visible) { + return null; + } + + return ( + + + {additionalPropertiesTitle && ( + + {additionalPropertiesTitle} + + )} + + {/* Add new property section */} + + + + 0} + helperText={newPropertyErrors.join(', ')} + size="small" + /> + + + + + + + + + + + + + + {/* Existing additional properties */} + {additionalPropertyItems.map((item) => ( + + + + {item.schema && item.uischema && ( + <> + {/* For objects with additionalProperties, render directly with MaterialAdditionalPropertiesRenderer */} + {item.schema.type === 'object' && item.schema.additionalProperties ? ( + + ) : ( + + )} + + )} + + {enabled && ( + + + + removeProperty(item.propertyName)} + disabled={removePropertyDisabled} + color="error" + > + + + + + + )} + + + ))} + + {additionalPropertyItems.length === 0 && ( + + No additional properties defined + + )} + + + ); +}; + +export default MaterialAdditionalPropertiesRenderer; \ No newline at end of file diff --git a/packages/material-renderers/src/additional/index.ts b/packages/material-renderers/src/additional/index.ts index 4583f9ffc..3caba40bf 100644 --- a/packages/material-renderers/src/additional/index.ts +++ b/packages/material-renderers/src/additional/index.ts @@ -30,11 +30,14 @@ import MaterialListWithDetailRenderer, { materialListWithDetailTester, } from './MaterialListWithDetailRenderer'; +import MaterialAdditionalPropertiesRenderer from './MaterialAdditionalPropertiesRenderer'; + export { MaterialLabelRenderer, materialLabelRendererTester, MaterialListWithDetailRenderer, materialListWithDetailTester, + MaterialAdditionalPropertiesRenderer, }; export * from './ListWithDetailMasterItem'; diff --git a/packages/material-renderers/src/additional/unwrapped.ts b/packages/material-renderers/src/additional/unwrapped.ts index e100a3d65..de61df7b2 100644 --- a/packages/material-renderers/src/additional/unwrapped.ts +++ b/packages/material-renderers/src/additional/unwrapped.ts @@ -26,7 +26,10 @@ import { MaterialLabelRenderer } from './MaterialLabelRenderer'; import { MaterialListWithDetailRenderer } from './MaterialListWithDetailRenderer'; +import MaterialAdditionalPropertiesRenderer from './MaterialAdditionalPropertiesRenderer'; + export const UnwrappedAdditional = { MaterialLabelRenderer, MaterialListWithDetailRenderer, + MaterialAdditionalPropertiesRenderer, }; diff --git a/packages/material-renderers/src/complex/MaterialObjectRenderer.tsx b/packages/material-renderers/src/complex/MaterialObjectRenderer.tsx index aeb170e01..aa47569e3 100644 --- a/packages/material-renderers/src/complex/MaterialObjectRenderer.tsx +++ b/packages/material-renderers/src/complex/MaterialObjectRenderer.tsx @@ -23,16 +23,19 @@ THE SOFTWARE. */ import isEmpty from 'lodash/isEmpty'; +import isObject from 'lodash/isObject'; import { + ControlWithDetailProps, findUISchema, Generate, isObjectControl, RankedTester, rankWith, - StatePropsOfControlWithDetail, + update, } from '@jsonforms/core'; -import { JsonFormsDispatch, withJsonFormsDetailProps } from '@jsonforms/react'; +import { JsonFormsDispatch, withJsonFormsDetailProps, withJsonFormsContext } from '@jsonforms/react'; import React, { useMemo } from 'react'; +import { MaterialAdditionalPropertiesRenderer } from '../additional'; export const MaterialObjectRenderer = ({ renderers, @@ -45,7 +48,10 @@ export const MaterialObjectRenderer = ({ enabled, uischema, rootSchema, -}: StatePropsOfControlWithDetail) => { + data, + handleChange, + config, +}: ControlWithDetailProps) => { const detailUiSchema = useMemo( () => findUISchema( @@ -57,29 +63,120 @@ export const MaterialObjectRenderer = ({ isEmpty(path) ? Generate.uiSchema(schema, 'VerticalLayout', undefined, rootSchema) : { - ...Generate.uiSchema(schema, 'Group', undefined, rootSchema), - label, - }, + ...Generate.uiSchema(schema, 'Group', undefined, rootSchema), + label, + }, uischema, rootSchema ), [uischemas, schema, uischema.scope, path, label, uischema, rootSchema] + ); const hasAdditionalProperties = useMemo( + () => + !isEmpty(schema.patternProperties) || + isObject(schema.additionalProperties) || + schema.additionalProperties === true, + [schema.patternProperties, schema.additionalProperties] ); + const showAdditionalProperties = useMemo(() => { + // Check config option to allow additional properties even if not defined in schema + const allowAdditionalPropertiesIfMissing = config?.allowAdditionalPropertiesIfMissing; + return ( + hasAdditionalProperties || + (allowAdditionalPropertiesIfMissing === true && + schema.additionalProperties === undefined) + ); + }, [hasAdditionalProperties, config, schema.additionalProperties]); + if (!visible) { return null; } + // If this object has no regular properties but has additional properties, + // don't render anything with JsonFormsDispatch - let MaterialAdditionalPropertiesRenderer handle everything + const shouldSkipJsonFormsDispatch = useMemo(() => { + const regularProperties = schema.properties || {}; + const hasRegularProperties = Object.keys(regularProperties).length > 0; + return !hasRegularProperties && hasAdditionalProperties; + }, [schema.properties, hasAdditionalProperties]); + + // When skipping JsonFormsDispatch, use the object's title for additional properties + const additionalPropertiesTitle = useMemo(() => { + if (shouldSkipJsonFormsDispatch && schema.title) { + // When skipping JsonFormsDispatch, use the object's title for the additional properties container + return schema.title; + } + return undefined; + }, [schema, shouldSkipJsonFormsDispatch]); + + // Create a filtered UI schema that excludes properties matching pattern properties + const filteredUiSchema = useMemo(() => { + if (shouldSkipJsonFormsDispatch || !schema.patternProperties || !data) { + return detailUiSchema; + } + + // Get existing data property names that match pattern properties + const dataKeys = Object.keys(data); + const patternMatchedKeys = dataKeys.filter(key => { + return Object.keys(schema.patternProperties!).some(pattern => + new RegExp(pattern).test(key) + ); + }); + + // If no pattern matches, use original UI schema + if (patternMatchedKeys.length === 0) { + return detailUiSchema; + } + + // Filter out controls for pattern-matched properties from UI schema + if (detailUiSchema.type === 'VerticalLayout' || detailUiSchema.type === 'Group') { + const layout = detailUiSchema as any; + return { + ...detailUiSchema, + elements: (layout.elements || []).filter((element: any) => { + if (element.type === 'Control' && element.scope) { + const propertyName = element.scope.replace('#/properties/', ''); + return !patternMatchedKeys.includes(propertyName); + } + return true; + }) + }; + } + + return detailUiSchema; + }, [detailUiSchema, shouldSkipJsonFormsDispatch, schema.patternProperties, data]); + return ( - +
+ {!shouldSkipJsonFormsDispatch && ( + + )} + {showAdditionalProperties && ( + + )} +
); }; @@ -88,4 +185,15 @@ export const materialObjectControlTester: RankedTester = rankWith( isObjectControl ); -export default withJsonFormsDetailProps(MaterialObjectRenderer); +const MaterialObjectRendererWithProps = withJsonFormsDetailProps(MaterialObjectRenderer); + +// Create a wrapper that adds the dispatch props +const MaterialObjectRendererWithDispatch = withJsonFormsContext(({ ctx, props }: any) => { + const dispatchProps = { + handleChange: (path: string, value: any) => { + ctx.dispatch(update(path, () => value)); + }, + }; + + return React.createElement(MaterialObjectRendererWithProps, { ...props, ...dispatchProps }); +}); export default MaterialObjectRendererWithDispatch; \ No newline at end of file diff --git a/packages/material-renderers/test/renderers/MaterialAdditionalPropertiesRenderer.test.tsx b/packages/material-renderers/test/renderers/MaterialAdditionalPropertiesRenderer.test.tsx new file mode 100644 index 000000000..11322739b --- /dev/null +++ b/packages/material-renderers/test/renderers/MaterialAdditionalPropertiesRenderer.test.tsx @@ -0,0 +1,197 @@ +/* + The MIT License + + Copyright (c) 2017-2019 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 * as React from 'react'; +import { ControlElement } from '@jsonforms/core'; +import { JsonFormsStateProvider } from '@jsonforms/react'; +import Enzyme, { mount, ReactWrapper } from 'enzyme'; +import Adapter from '@wojtekmaj/enzyme-adapter-react-17'; +import MaterialAdditionalPropertiesRenderer from '../../src/additional/MaterialAdditionalPropertiesRenderer'; +import { materialRenderers } from '../../src'; +import { initCore } from './util'; + +Enzyme.configure({ adapter: new Adapter() }); + +const schema = { + type: 'object', + properties: { + name: { + type: 'string', + }, + }, + additionalProperties: { + type: 'string', + }, +}; + +const uischema: ControlElement = { + type: 'Control', + scope: '#/', +}; + +const data = { + name: 'John Doe', + additionalProp1: 'value1', + additionalProp2: 'value2', +}; + +describe('MaterialAdditionalPropertiesRenderer', () => { + let wrapper: ReactWrapper; + + afterEach(() => { + if (wrapper) { + wrapper.unmount(); + } + }); + + test('should render additional properties', () => { + wrapper = mount( + + {}} + enabled={true} + visible={true} + renderers={materialRenderers} + cells={[]} + config={{}} + label="" + uischema={uischema} + /> + + ); + + expect(wrapper.find('input[value="additionalProp1"]')).toHaveLength(0); // Property names are not editable + expect(wrapper.find('input')).not.toHaveLength(0); // But there should be input fields for values and new property name + }); + + test('should allow adding new properties', () => { + const handleChange = jest.fn(); + + wrapper = mount( + + + + ); + + // Find the property name input field + const propertyNameInput = wrapper.find('input[label="Property Name"]').first(); + propertyNameInput.simulate('change', { target: { value: 'newProp' } }); + + // Find and click the add button + const addButton = wrapper.find('button').filterWhere(n => + n.find('AddIcon').length > 0 + ); + + expect(addButton).toHaveLength(1); + addButton.simulate('click'); + + // Verify handleChange was called + expect(handleChange).toHaveBeenCalled(); + }); + + test('should not render when not visible', () => { + wrapper = mount( + + {}} + enabled={true} + visible={false} + renderers={materialRenderers} + cells={[]} + config={{}} + label="" + uischema={uischema} + /> + + ); + + expect(wrapper.isEmptyRender()).toBe(true); + }); + + test('should disable controls when not enabled', () => { + wrapper = mount( + + {}} + enabled={false} + visible={true} + renderers={materialRenderers} + cells={[]} + config={{}} + label="" + uischema={uischema} + /> + + ); + + // Check that inputs are disabled + const inputs = wrapper.find('input'); + inputs.forEach(input => { + expect(input.prop('disabled')).toBe(true); + }); + + // Check that buttons are disabled + const buttons = wrapper.find('button'); + buttons.forEach(button => { + expect(button.prop('disabled')).toBe(true); + }); + }); +}); \ No newline at end of file