quintodrome/ui/src/plugin/SchemaConfigEditor.jsx

239 lines
6.5 KiB
JavaScript

import React, { useCallback, useEffect, useMemo, useRef } from 'react'
import PropTypes from 'prop-types'
import { JsonForms } from '@jsonforms/react'
import { materialRenderers, materialCells } from '@jsonforms/material-renderers'
import { makeStyles } from '@material-ui/core/styles'
import { Typography } from '@material-ui/core'
import { useTranslate } from 'react-admin'
import Ajv from 'ajv'
import {
OutlinedTextRenderer,
OutlinedNumberRenderer,
OutlinedEnumRenderer,
OutlinedOneOfEnumRenderer,
} from './OutlinedRenderers'
// Error boundary for catching JSONForms rendering errors
class SchemaErrorBoundary extends React.Component {
constructor(props) {
super(props)
this.state = { hasError: false, error: null }
}
static getDerivedStateFromError(error) {
return { hasError: true, error }
}
render() {
if (this.state.hasError) {
return this.props.fallback(this.state.error)
}
return this.props.children
}
}
SchemaErrorBoundary.propTypes = {
children: PropTypes.node.isRequired,
fallback: PropTypes.func.isRequired,
}
// Custom AJV instance that fixes "required" error paths for JSONForms.
// AJV outputs required errors pointing to the parent (e.g., "/users/1") with
// params.missingProperty. We transform them to point to the field directly
// (e.g., "/users/1/username") so JSONForms displays them under the correct input.
const ajv = new Ajv({
useDefaults: true,
allErrors: true,
verbose: true,
jsonPointers: true,
})
const origCompile = ajv.compile.bind(ajv)
ajv.compile = (schema) => {
const validate = origCompile(schema)
const wrapped = (data) => {
const valid = validate(data)
validate.errors?.forEach((e) => {
if (e.keyword === 'required' && e.params?.missingProperty) {
e.dataPath = `${e.dataPath || ''}/${e.params.missingProperty}`
}
})
wrapped.errors = validate.errors
return valid
}
wrapped.schema = validate.schema
return wrapped
}
const useStyles = makeStyles(
(theme) => ({
root: {
'& .MuiFormControl-root': {
marginBottom: theme.spacing(2),
},
// Label elements (type: "Label" in UI schema) - make slightly smaller
'& .MuiTypography-h6': {
fontSize: '0.95rem',
},
// Group/array styling
'& .MuiPaper-root': {
backgroundColor: 'transparent',
},
// Array items styling
'& .MuiAccordion-root': {
marginBottom: theme.spacing(1),
'&:before': {
display: 'none',
},
},
'& .MuiAccordionSummary-root': {
backgroundColor:
theme.palette.type === 'dark'
? theme.palette.grey[800]
: theme.palette.grey[100],
// Hide expand icon - items are always expanded
'& .MuiAccordionSummary-expandIcon': {
display: 'none',
},
},
// Checkbox/switch styling
'& .MuiCheckbox-root, & .MuiSwitch-root': {
color: theme.palette.text.secondary,
},
'& .Mui-checked': {
color: theme.palette.primary.main,
},
},
errorContainer: {
padding: theme.spacing(2),
backgroundColor:
theme.palette.type === 'dark'
? 'rgba(244, 67, 54, 0.1)'
: 'rgba(244, 67, 54, 0.05)',
borderRadius: theme.shape.borderRadius,
border: `1px solid ${theme.palette.error.main}`,
},
errorMessage: {
color: theme.palette.error.main,
marginBottom: theme.spacing(1),
},
errorDetails: {
color: theme.palette.text.secondary,
fontSize: '0.85em',
fontFamily: 'monospace',
whiteSpace: 'pre-wrap',
wordBreak: 'break-word',
},
}),
{ name: 'NDSchemaConfigEditor' },
)
// Custom renderers with outlined text inputs and always-expanded array layout
const customRenderers = [
// Put our custom renderers first (higher priority)
OutlinedTextRenderer,
OutlinedNumberRenderer,
OutlinedEnumRenderer,
OutlinedOneOfEnumRenderer,
// Then all the standard material renderers
...materialRenderers,
]
export const SchemaConfigEditor = ({
schema,
uiSchema,
data,
onChange,
readOnly = false,
}) => {
const classes = useStyles()
const translate = useTranslate()
const containerRef = useRef(null)
// Disable browser autocomplete on all inputs
useEffect(() => {
if (!containerRef.current) return
const disableAutocomplete = () => {
const inputs = containerRef.current.querySelectorAll('input')
inputs.forEach((input) => {
input.setAttribute('autocomplete', 'off')
})
}
// Run immediately and observe for changes (new inputs added)
disableAutocomplete()
const observer = new MutationObserver(disableAutocomplete)
observer.observe(containerRef.current, { childList: true, subtree: true })
return () => observer.disconnect()
}, [data])
// Memoize the change handler to extract just the data
const handleChange = useCallback(
({ data: newData, errors }) => {
if (onChange) {
onChange(newData, errors)
}
},
[onChange],
)
// Use custom renderers with always-expanded array layout
const renderers = useMemo(() => customRenderers, [])
const cells = useMemo(() => materialCells, [])
// JSONForms config - always show descriptions
const config = {
showUnfocusedDescription: true,
}
// Ensure schema has required fields for JSONForms
const normalizedSchema = useMemo(() => {
if (!schema) return null
// JSONForms requires type to be set at root level
return {
type: 'object',
...schema,
}
}, [schema])
if (!normalizedSchema) {
return null
}
const renderError = (error) => (
<div className={classes.errorContainer}>
<Typography className={classes.errorMessage}>
{translate('resources.plugin.messages.schemaRenderError')}
</Typography>
<Typography className={classes.errorDetails}>{error?.message}</Typography>
</div>
)
return (
<div ref={containerRef} className={classes.root}>
<SchemaErrorBoundary fallback={renderError}>
<JsonForms
schema={normalizedSchema}
uischema={uiSchema}
data={data || {}}
renderers={renderers}
cells={cells}
config={config}
onChange={handleChange}
readonly={readOnly}
ajv={ajv}
validationMode="ValidateAndShow"
/>
</SchemaErrorBoundary>
</div>
)
}
SchemaConfigEditor.propTypes = {
schema: PropTypes.object,
uiSchema: PropTypes.object,
data: PropTypes.object,
onChange: PropTypes.func,
readOnly: PropTypes.bool,
}