Hi @sdirix,
Thank you for your assistance so far.
I have attempted to replicate the vuetify solution in react and have made progress but still have a lot of issues. The primary issue I want to discuss first is adding enums that are defined in patternProperties.
I’ll share my patternProperties/additionalProperties solution so far, perhaps you could take a look and share your thoughts on whether it’s implemented correctly. I’ll omit anything specific to my app but will leave relevant stuff for JSON Forms.
Heres the function I call JSON Forms from initially:
//imports
const ShowForm = ({ dataJSON }) => {
//useStates/useEffects/APIcalls etc...
const renderers = [
{
tester: ObjectRendererTester,
renderer: ObjectRenderer
},
...materialRenderers
];
return (
<Card elevation={0} variant="outlined">
<CardHeader title={`JSON Form - ${dataJSON.name}`} />
<CardContent>
<JsonForms
schema={dataSchema}
uischema={uiSchema}
data={formJSON}
renderers={renderers}
cells={materialCells}
onChange={({ data }) => setJSON(data)}
validationMode={validation}
/>
<Button onClick={handleSave} className="mt-3 mb-2">
Submit
</Button>
</CardContent>
</Card>
);
};
export default ShowForm;
Heres my tester:
import { rankWith, isObjectControl } from '@jsonforms/core';
export default rankWith(3, isObjectControl);
ObjectRenderer:
import React from 'react';
import { findUISchema, Generate } from '@jsonforms/core';
import { withJsonFormsControlProps, JsonFormsDispatch } from '@jsonforms/react';
import cloneDeep from 'lodash/cloneDeep';
import isEmpty from 'lodash/isEmpty';
import AdditionalProperties from './AdditionalProperties';
const ObjectRenderer = (props) => {
const { visible, enabled, uischemas, schema, uischema, path, rootSchema } = props;
const hasAdditionalProperties =
!isEmpty(schema.patternProperties) || typeof schema.additionalProperties === 'object';
const detailUiSchema = () => {
const uiSchemaGenerator = () => {
const uiSchema = Generate.uiSchema(schema, 'Group');
if (isEmpty(path)) {
uiSchema.type = 'VerticalLayout';
} else {
uiSchema.label = uischema.label;
}
return uiSchema;
};
let result = findUISchema(
uischemas,
schema,
uischema.scope,
path,
uiSchemaGenerator,
uischema,
rootSchema
);
if (props.nested && props.nested.level > 0) {
result = cloneDeep(result);
result.options = {
...result.options,
bare: true,
alignLeft: props.nested.level >= 4 || props.nested.parentElement === 'array'
};
}
return result;
};
return (
<div>
<JsonFormsDispatch
visible={visible}
enabled={enabled}
schema={schema}
uischema={detailUiSchema()}
path={path}
/>
{hasAdditionalProperties && <AdditionalProperties props={props} />}
</div>
);
};
export default withJsonFormsControlProps(ObjectRenderer);
AdditionalProperties:
import React, { useState, useEffect, useMemo } from 'react';
import { composePaths, createControlElement, createDefaultValue, Generate } from '@jsonforms/core';
import { JsonFormsDispatch } from '@jsonforms/react';
import Ajv from 'ajv';
import {
TextField,
IconButton,
Tooltip,
Card,
CardContent,
CardHeader,
Grid,
Box
} from '@mui/material';
import { Add as AddIcon, Delete as DeleteIcon } from '@mui/icons-material';
const reuseAjvForSchema = (ajv, schema) => {
if (schema && (schema.id || schema.$id)) {
ajv.removeSchema(schema);
}
return ajv;
};
const AdditionalProperties = ({ props }) => {
const [newPropertyName, setNewPropertyName] = useState('');
const [additionalPropertyItems, setAdditionalPropertyItems] = useState([]);
const ajv = useMemo(() => new Ajv(), []);
const reservedPropertyNames = useMemo(() => {
return Object.keys((props.schema && props.schema.properties) || {});
}, [props.schema]);
const additionalKeys = useMemo(() => {
return props.data && typeof props.data === 'object'
? Object.keys(props.data).filter((key) => !reservedPropertyNames.includes(key))
: [];
}, [props.data, reservedPropertyNames]);
useEffect(() => {
const items = additionalKeys.map((propName) =>
toAdditionalPropertyType(propName, props.data[propName])
);
setAdditionalPropertyItems(items);
}, [additionalKeys]);
const toAdditionalPropertyType = (propName, propValue) => {
let propSchema = undefined;
let propUiSchema = undefined;
if (props.schema?.patternProperties && typeof props.schema.patternProperties === 'object') {
const matchedPattern = Object.keys(props.schema.patternProperties).find((pattern) =>
new RegExp(pattern).test(propName)
);
if (matchedPattern) {
propSchema = props.schema.patternProperties[matchedPattern];
}
}
if (
!propSchema &&
props.schema?.additionalProperties &&
typeof props.schema.additionalProperties === 'object'
) {
propSchema = props.schema.additionalProperties;
}
if (!propSchema && propValue !== undefined) {
propSchema = Generate.jsonSchema({ prop: propValue }).properties?.prop;
}
if (propSchema) {
if (propSchema.type === 'object' || propSchema.type === 'array') {
propUiSchema = Generate.uiSchema(propSchema, 'Group');
} else {
propUiSchema = createControlElement(composePaths(props.path, propName));
}
}
return {
propertyName: propName,
path: composePaths(props.path, propName),
schema: propSchema,
uischema: propUiSchema
};
};
const propertyNameValidator = useMemo(() => {
let schema = undefined;
if (props.schema?.patternProperties && typeof props.schema.patternProperties === 'object') {
const patternKeys = Object.keys(props.schema.patternProperties).join('|');
schema = { type: 'string', pattern: patternKeys };
}
return schema ? reuseAjvForSchema(ajv, schema).compile(schema) : undefined;
}, [props.schema, ajv]);
const handleAddProperty = () => {
if (newPropertyName) {
const newProperty = toAdditionalPropertyType(newPropertyName, undefined);
setAdditionalPropertyItems([...additionalPropertyItems, newProperty]);
if (props.data && typeof props.data === 'object') {
props.data[newPropertyName] = createDefaultValue(newProperty.schema);
props.handleChange(props.path, props.data);
}
setNewPropertyName('');
}
};
const handleRemoveProperty = (propertyName) => {
setAdditionalPropertyItems(
additionalPropertyItems.filter((item) => item.propertyName !== propertyName)
);
if (props.data && typeof props.data === 'object') {
delete props.data[propertyName];
props.handleChange(props.path, props.data);
}
};
const newPropertyErrors = () => {
if (newPropertyName) {
const messages = [];
if (propertyNameValidator) {
const valid = propertyNameValidator(newPropertyName);
if (!valid) {
messages.push(propertyNameValidator.errors.map((error) => error.message).join(', '));
}
}
if (reservedPropertyNames.includes(newPropertyName)) {
messages.push(`Property '${newPropertyName}' is already defined`);
}
if (additionalPropertyItems.find((ap) => ap.propertyName === newPropertyName)) {
messages.push(`Property '${newPropertyName}' is already defined`);
}
return messages;
}
return [];
};
const addPropertyDisabled = useMemo(() => {
const errors = newPropertyErrors();
return (
!props.enabled ||
(props.schema?.maxProperties !== undefined &&
Object.keys(props.data).length >= props.schema.maxProperties) ||
errors.length > 0 ||
!newPropertyName
);
}, [props, newPropertyName]);
return (
<Card elevation={0}>
<CardHeader
title={props.schema.title || 'Additional/Pattern Properties'}
action={
<Tooltip title="Add Property">
<span>
<IconButton
aria-label="add"
onClick={handleAddProperty}
disabled={addPropertyDisabled}>
<AddIcon />
</IconButton>
</span>
</Tooltip>
}
/>
<CardContent>
<TextField
label="New Property"
value={newPropertyName}
onChange={(event) => setNewPropertyName(event.target.value)}
error={newPropertyErrors().length > 0}
helperText={newPropertyErrors().join(', ')}
disabled={!props.enabled}
sx={{ mb: 2 }}
/>
<Grid container spacing={2}>
{additionalPropertyItems.map((element, index) => (
<Grid
item
xs={12}
key={index}
sx={{
boxShadow: 2,
borderRadius: 1,
p: 2,
minWidth: 300,
marginTop: 2
}}>
<Box display="flex" alignItems="center" justifyContent="space-between">
<CardHeader title={element.propertyName} sx={{ flexGrow: 1, paddingBottom: 2 }} />
<Tooltip title="Delete Property">
<IconButton
aria-label="delete"
onClick={() => handleRemoveProperty(element.propertyName)}
disabled={!props.enabled}>
<DeleteIcon />
</IconButton>
</Tooltip>
</Box>
<JsonFormsDispatch
schema={element.schema}
uischema={element.uischema}
path={element.path}
enabled={props.enabled}
/>
</Grid>
))}
</Grid>
</CardContent>
</Card>
);
};
export default AdditionalProperties;
So with the above, I get a similar outcome to that presented in the Vuetify solution with the default schema/uischema/form etc.
When using an enum in a schema such as:
//rest of schema
"address": {
"type": "object",
"properties": {
"street": {
"type": "string",
"enum": ["Main St", "Second St", "Third St"]
},
"city": {
"type": "string",
"enum": ["New York", "Los Angeles", "Chicago"]
},
"postalCode": {
"type": "string",
"enum": ["10001", "90001", "60601"]
}
},
"required": ["street", "city"]
}
//rest of schema
Everything works well, the enums are populated. I can trace that MuiAutocomplete line 85 is hit:
const findOption = options.find((o) => o.value === data) ?? null;
Options is defined with something like:
{
label: “Main St”,
value: “Main St”,
}
for each of the items. (though ‘data’ is undefined, I’ll come back to this)
However, when attempting to use enums that have been selected from patternProperties such as:
//rest of schema
"patternProperties": {
"^enum$": {
"enum": [
"foo",
"bar",
"foobar"
],
"type": "string"
},
//rest of schema
I experience the error:
ERROR
Cannot read properties of undefined (reading 'find')
TypeError: Cannot read properties of undefined (reading 'find')
at MuiAutocomplete (http://localhost:3001/dbapp/static/js/bundle.js:20460:30)
at renderWithHooks (http://localhost:3001/dbapp/static/js/bundle.js:140482:22)
at mountIndeterminateComponent (http://localhost:3001/dbapp/static/js/bundle.js:143766:17)
at beginWork (http://localhost:3001/dbapp/static/js/bundle.js:145062:20)
at HTMLUnknownElement.callCallback (http://localhost:3001/dbapp/static/js/bundle.js:130078:18)
at Object.invokeGuardedCallbackDev (http://localhost:3001/dbapp/static/js/bundle.js:130122:20)
at invokeGuardedCallback (http://localhost:3001/dbapp/static/js/bundle.js:130179:35)
at beginWork$1 (http://localhost:3001/dbapp/static/js/bundle.js:150043:11)
at performUnitOfWork (http://localhost:3001/dbapp/static/js/bundle.js:149291:16)
at workLoopSync (http://localhost:3001/dbapp/static/js/bundle.js:149214:9)
So when it hits MuiAutocomplete line 85 ‘options’ is undefined (as is data).
I attempted to troubleshoot and found this function:
export const createDefaultValue = (
schema: JsonSchema,
rootSchema: JsonSchema
) => {
const resolvedSchema = Resolve.schema(schema, schema.$ref, rootSchema);
if (resolvedSchema.default !== undefined) {
return extractDefaults(resolvedSchema, rootSchema);
}
if (hasType(resolvedSchema, 'string')) {
if (
resolvedSchema.format === 'date-time' ||
resolvedSchema.format === 'date' ||
resolvedSchema.format === 'time'
) {
return convertDateToString(new Date(), resolvedSchema.format);
}
return '';
} else if (
hasType(resolvedSchema, 'integer') ||
hasType(resolvedSchema, 'number')
) {
return 0;
} else if (hasType(resolvedSchema, 'boolean')) {
return false;
} else if (hasType(resolvedSchema, 'array')) {
return [];
} else if (hasType(resolvedSchema, 'object')) {
return extractDefaults(resolvedSchema, rootSchema);
} else if (hasType(resolvedSchema, 'null')) {
return null;
} else {
return {};
}
};
Doesn’t seem to handle enums. I tried this:
export const createDefaultValue = (
schema: JsonSchema,
rootSchema: JsonSchema
) => {
const resolvedSchema = Resolve.schema(schema, schema.$ref, rootSchema);
// Check if a default value is specified in the schema
if (resolvedSchema.default !== undefined) {
return extractDefaults(resolvedSchema, rootSchema);
}
// Handle enum values
if (Array.isArray(resolvedSchema.enum) && resolvedSchema.enum.length > 0) {
return resolvedSchema.enum[0]; // Choose the first value in the enum list
}
if (hasType(resolvedSchema, 'string')) {
if (
resolvedSchema.format === 'date-time' ||
resolvedSchema.format === 'date' ||
resolvedSchema.format === 'time'
) {
return convertDateToString(new Date(), resolvedSchema.format);
}
return '';
} else if (
hasType(resolvedSchema, 'integer') ||
hasType(resolvedSchema, 'number')
) {
return 0;
} else if (hasType(resolvedSchema, 'boolean')) {
return false;
} else if (hasType(resolvedSchema, 'array')) {
return [];
} else if (hasType(resolvedSchema, 'object')) {
return extractDefaults(resolvedSchema, rootSchema);
} else if (hasType(resolvedSchema, 'null')) {
return null;
} else {
return {};
}
};
And found that when I reach line 85, data was now = ‘foo’ but options is still, of course, undefined.
I can quite figure out how to trace where options is defined to ensure it is populated and doesn’t throw an error, appreciate any advice.
Heres a json schema you can test in Vuetify and see the same issue (note enums out of patternProperties are fine but ones added through patternProperties are not):
{
"$schema": "http://json-schema.org/draft-07/schema#",
"type": "object",
"properties": {
"status": {
"type": "string",
"enum": ["pending", "approved", "rejected"]
},
"priority": {
"type": "string",
"enum": ["low", "medium", "high"]
},
"category": {
"type": "string",
"enum": ["bug", "feature", "task"]
},
"details": {
"type": "object",
"properties": {
"name": {
"type": "string"
},
"age": {
"type": "integer",
"minimum": 0,
"enum": [18, 25, 30, 40]
},
"gender": {
"type": "string",
"enum": ["male", "female", "other"]
}
},
"required": ["name", "age", "gender"]
},
"additionalInfo": {
"type": "object",
"properties": {
"description": {
"type": "string"
},
"type": {
"type": "string",
"enum": ["note", "warning", "info"]
}
},
"patternProperties": {
"^info[0-9]+$": {
"type": "string",
"enum": ["info1", "info2", "info3"]
}
}
}
},
"definitions": {
"address": {
"type": "object",
"properties": {
"street": {
"type": "string",
"enum": ["Main St", "Second St", "Third St"]
},
"city": {
"type": "string",
"enum": ["New York", "Los Angeles", "Chicago"]
},
"postalCode": {
"type": "string",
"enum": ["10001", "90001", "60601"]
}
},
"required": ["street", "city"]
},
"contact": {
"type": "object",
"properties": {
"email": {
"type": "string",
"format": "email"
},
"phone": {
"type": "string",
"pattern": "^\\+?[1-9]\\d{1,14}$"
}
},
"required": ["email"]
}
},
"patternProperties": {
"^customProperty[0-9]+$": {
"$ref": "#/definitions/address"
},
"^contact[0-9]+$": {
"$ref": "#/definitions/contact"
}
},
"additionalProperties": false
}
Thanks in advance!
Moo