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>
This commit is contained in:
Deluan Quintão 2026-01-19 20:51:00 -05:00 • committed by GitHub
commit f1e75c40dc
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
40 changed files with 5418 additions and 1995 deletions

View file

@ -1,55 +1,119 @@
import React, { useCallback } from 'react'
import {
Card,
CardContent,
Typography,
TextField as MuiTextField,
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableRow,
IconButton,
Paper,
} from '@material-ui/core'
import { MdDelete } from 'react-icons/md'
import React, { useCallback, useState, useMemo } from 'react'
import PropTypes from 'prop-types'
import { Card, CardContent, Typography, Box } from '@material-ui/core'
import Alert from '@material-ui/lab/Alert'
import { SchemaConfigEditor } from './SchemaConfigEditor'
// Navigate schema by path parts to find the title for a field
const findFieldTitle = (schema, parts) => {
let currentSchema = schema
let fieldName = parts[parts.length - 1] // Default to last part
for (const part of parts) {
if (!currentSchema) break
// Skip array indices (just move to items schema)
if (/^\d+$/.test(part)) {
if (currentSchema.items) {
currentSchema = currentSchema.items
}
continue
}
// Navigate to property and always update fieldName
if (currentSchema.properties?.[part]) {
const propSchema = currentSchema.properties[part]
fieldName = propSchema.title || part
currentSchema = propSchema
}
}
return fieldName
}
// Extract human-readable field name from JSONForms error
const getFieldName = (error, schema) => {
// JSONForms errors can have different path formats:
// - dataPath: "users.1.token" (dot-separated)
// - instancePath: "/users/1/token" (slash-separated)
// - property: "users.1.username" (dot-separated)
const dataPath = error.dataPath || ''
const instancePath = error.instancePath || ''
const property = error.property || ''
// Try dataPath first (dot-separated like "users.1.token")
if (dataPath) {
const parts = dataPath.split('.').filter(Boolean)
if (parts.length > 0) {
return findFieldTitle(schema, parts)
}
}
// Try property (also dot-separated)
if (property) {
const parts = property.split('.').filter(Boolean)
if (parts.length > 0) {
return findFieldTitle(schema, parts)
}
}
// Fall back to instancePath (slash-separated like "/users/1/token")
if (instancePath) {
const parts = instancePath.split('/').filter(Boolean)
if (parts.length > 0) {
return findFieldTitle(schema, parts)
}
}
// Try to extract from schemaPath like "#/properties/users/items/properties/username/minLength"
const schemaPath = error.schemaPath || ''
const propMatches = [...schemaPath.matchAll(/\/properties\/([^/]+)/g)]
if (propMatches.length > 0) {
const parts = propMatches.map((m) => m[1])
return findFieldTitle(schema, parts)
}
return null
}
export const ConfigCard = ({
configPairs,
onConfigPairsChange,
manifest,
configData,
onConfigDataChange,
classes,
translate,
}) => {
const handleKeyChange = useCallback(
(index, newKey) => {
const newPairs = [...configPairs]
newPairs[index] = { ...newPairs[index], key: newKey }
onConfigPairsChange(newPairs)
const [validationErrors, setValidationErrors] = useState([])
// Handle changes from JSONForms
const handleChange = useCallback(
(newData, errors) => {
setValidationErrors(errors || [])
onConfigDataChange(newData, errors)
},
[configPairs, onConfigPairsChange],
[onConfigDataChange],
)
const handleValueChange = useCallback(
(index, newValue) => {
const newPairs = [...configPairs]
newPairs[index] = { ...newPairs[index], value: newValue }
onConfigPairsChange(newPairs)
},
[configPairs, onConfigPairsChange],
)
// Only show config card if manifest has config schema defined
const hasConfigSchema = manifest?.config?.schema
const handleDeleteRow = useCallback(
(index) => {
const newPairs = configPairs.filter((_, i) => i !== index)
onConfigPairsChange(newPairs)
},
[configPairs, onConfigPairsChange],
)
// Format validation errors with proper field names
const formattedErrors = useMemo(() => {
if (!hasConfigSchema) {
return []
}
const { schema } = manifest.config
return validationErrors.map((error) => ({
fieldName: getFieldName(error, schema),
message: error.message,
}))
}, [validationErrors, manifest, hasConfigSchema])
const handleAddRow = useCallback(() => {
onConfigPairsChange([...configPairs, { key: '', value: '' }])
}, [configPairs, onConfigPairsChange])
if (!hasConfigSchema) {
return null
}
const { schema, uiSchema } = manifest.config
return (
<Card className={classes.section}>
@ -57,95 +121,44 @@ export const ConfigCard = ({
<Typography variant="h6" className={classes.sectionTitle}>
{translate('resources.plugin.sections.configuration')}
</Typography>
<Typography variant="body2" color="textSecondary" gutterBottom>
{translate('resources.plugin.messages.configHelp')}
</Typography>
<TableContainer component={Paper} variant="outlined">
<Table size="small" className={classes.configTable}>
<TableHead>
<TableRow>
<TableCell width="40%">
{translate('resources.plugin.fields.configKey')}
</TableCell>
<TableCell width="50%">
{translate('resources.plugin.fields.configValue')}
</TableCell>
<TableCell width="10%" align="right">
<IconButton
size="small"
onClick={handleAddRow}
aria-label={translate('resources.plugin.actions.addConfig')}
className={classes.configActionIconButton}
>
+
</IconButton>
</TableCell>
</TableRow>
</TableHead>
<TableBody>
{configPairs.map((pair, index) => (
<TableRow key={index}>
<TableCell>
<MuiTextField
fullWidth
size="small"
variant="outlined"
value={pair.key}
onChange={(e) => handleKeyChange(index, e.target.value)}
placeholder={translate(
'resources.plugin.placeholders.configKey',
)}
InputProps={{
className: classes.configTableInput,
}}
/>
</TableCell>
<TableCell>
<MuiTextField
fullWidth
size="small"
variant="outlined"
multiline
minRows={1}
value={pair.value}
onChange={(e) => handleValueChange(index, e.target.value)}
placeholder={translate(
'resources.plugin.placeholders.configValue',
)}
InputProps={{
className: classes.configTableInput,
}}
inputProps={{
style: { resize: 'vertical' },
}}
/>
</TableCell>
<TableCell align="right">
<IconButton
size="small"
onClick={() => handleDeleteRow(index)}
aria-label={translate('ra.action.delete')}
className={classes.configActionIconButton}
>
<MdDelete />
</IconButton>
</TableCell>
</TableRow>
))}
{configPairs.length === 0 && (
<TableRow>
<TableCell colSpan={3} align="center">
<Typography variant="body2" color="textSecondary">
{translate('resources.plugin.messages.noConfig')}
</Typography>
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</TableContainer>
{formattedErrors.length > 0 && (
<Box mb={2}>
<Alert severity="error">
{translate('resources.plugin.messages.configValidationError')}
<ul style={{ margin: '8px 0 0', paddingLeft: 20 }}>
{formattedErrors.map((error, index) => (
<li key={index}>
{error.fieldName && <strong>{error.fieldName}</strong>}
{error.fieldName && ': '}
{error.message}
</li>
))}
</ul>
</Alert>
</Box>
)}
<SchemaConfigEditor
schema={schema}
uiSchema={uiSchema}
data={configData}
onChange={handleChange}
/>
</CardContent>
</Card>
)
}
ConfigCard.propTypes = {
manifest: PropTypes.shape({
config: PropTypes.shape({
schema: PropTypes.object,
uiSchema: PropTypes.object,
}),
}),
configData: PropTypes.object,
onConfigDataChange: PropTypes.func.isRequired,
classes: PropTypes.object.isRequired,
translate: PropTypes.func.isRequired,
}