navidrome/ui/src/plugin/SchemaConfigEditor.jsx
Deluan Quintão f1e75c40dc
feat(plugins): add JSONForms-based plugin configuration UI (#4911)
* feat(plugins): add JSONForms schema for plugin configuration

Signed-off-by: Deluan <deluan@navidrome.org>

* feat: enhance error handling by formatting validation errors with field names

Signed-off-by: Deluan <deluan@navidrome.org>

* feat: enforce required fields in config validation and improve error handling

Signed-off-by: Deluan <deluan@navidrome.org>

* format JS code

Signed-off-by: Deluan <deluan@navidrome.org>

* feat: add config schema validation and enhance manifest structure

Signed-off-by: Deluan <deluan@navidrome.org>

* feat: refactor plugin config parsing and add unit tests

Signed-off-by: Deluan <deluan@navidrome.org>

* feat: add config validation error message in Portuguese

* feat: enhance AlwaysExpandedArrayLayout with description support and improve array control testing

Signed-off-by: Deluan <deluan@navidrome.org>

* feat: update Discord Rust plugin configuration to use JSONForm for user tokens and enhance schema validation

Signed-off-by: Deluan <deluan@navidrome.org>

* fix: resolve React Hooks linting issues in plugin UI components

* Apply suggestions from code review

Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>

* format code

Signed-off-by: Deluan <deluan@navidrome.org>

* feat: migrate schema validation to use santhosh-tekuri/jsonschema and improve error formatting

Signed-off-by: Deluan <deluan@navidrome.org>

* address PR comments

Signed-off-by: Deluan <deluan@navidrome.org>

* fix flaky test

Signed-off-by: Deluan <deluan@navidrome.org>

* feat: enhance array layout and configuration handling with AJV defaults

Signed-off-by: Deluan <deluan@navidrome.org>

* feat: implement custom tester to exclude enum arrays from AlwaysExpandedArrayLayout

Signed-off-by: Deluan <deluan@navidrome.org>

* feat: add error boundary for schema rendering and improve error messages

Signed-off-by: Deluan <deluan@navidrome.org>

* feat: refine non-enum array control logic by utilizing JSONForms schema resolution

Signed-off-by: Deluan <deluan@navidrome.org>

* feat: add error styling to ToggleEnabledSwitch for disabled state

Signed-off-by: Deluan <deluan@navidrome.org>

* feat: adjust label positioning and styling in SchemaConfigEditor for improved layout

Signed-off-by: Deluan <deluan@navidrome.org>

* feat: implement outlined input controls renderers to replace custom fragile CSS

Signed-off-by: Deluan <deluan@navidrome.org>

* feat: remove margin from last form control inside array items for better spacing

Signed-off-by: Deluan <deluan@navidrome.org>

* feat: enhance AJV error handling to transform required errors for field-level validation

Signed-off-by: Deluan <deluan@navidrome.org>

* feat: set default value for User Tokens in manifest.json to improve user experience

Signed-off-by: Deluan <deluan@navidrome.org>

* format

Signed-off-by: Deluan <deluan@navidrome.org>

* feat: add margin to outlined input controls for improved spacing

Signed-off-by: Deluan <deluan@navidrome.org>

* feat: remove redundant margin rule for last form control in array items

Signed-off-by: Deluan <deluan@navidrome.org>

* feat: adjust font size of label elements in SchemaConfigEditor for improved readability

Signed-off-by: Deluan <deluan@navidrome.org>

---------

Signed-off-by: Deluan <deluan@navidrome.org>
Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
2026-01-19 20:51:00 -05:00

242 lines
6.6 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 { AlwaysExpandedArrayLayout } from './AlwaysExpandedArrayLayout'
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,
AlwaysExpandedArrayLayout,
// 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,
}