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
parent 66474fc9f4
commit f1e75c40dc
40 changed files with 5430 additions and 2007 deletions
+4 -5
View File
@@ -318,11 +318,10 @@ export const SelectPlaylistInput = ({ onChange }) => {
const canCreateNew = Boolean(
searchText.trim() &&
!filteredOptions.some(
(option) =>
option.name.toLowerCase() === searchText.toLowerCase().trim(),
) &&
!selectedPlaylists.some((p) => p.name === searchText.trim()),
!filteredOptions.some(
(option) => option.name.toLowerCase() === searchText.toLowerCase().trim(),
) &&
!selectedPlaylists.some((p) => p.name === searchText.trim()),
)
return (
+2
View File
@@ -388,6 +388,8 @@
},
"messages": {
"configHelp": "Configure the plugin using key-value pairs. Leave empty if the plugin requires no configuration.",
"configValidationError": "Configuration validation failed:",
"schemaRenderError": "Unable to render configuration form. The plugin's schema may be invalid.",
"clickPermissions": "Click a permission for details",
"noConfig": "No configuration set",
"allUsersHelp": "When enabled, the plugin will have access to all users, including those created in the future.",
+276
View File
@@ -0,0 +1,276 @@
import React, { useCallback, useMemo } from 'react'
import {
composePaths,
computeLabel,
createDefaultValue,
isObjectArrayWithNesting,
isPrimitiveArrayControl,
rankWith,
findUISchema,
Resolve,
} from '@jsonforms/core'
import {
JsonFormsDispatch,
withJsonFormsArrayLayoutProps,
} from '@jsonforms/react'
import range from 'lodash/range'
import merge from 'lodash/merge'
import { Box, IconButton, Tooltip, Typography } from '@material-ui/core'
import { Add, Delete } from '@material-ui/icons'
import { makeStyles } from '@material-ui/core/styles'
const useStyles = makeStyles((theme) => ({
arrayItem: {
position: 'relative',
padding: theme.spacing(2),
marginBottom: theme.spacing(2),
border: `1px solid ${theme.palette.divider}`,
borderRadius: theme.shape.borderRadius,
'&:last-child': {
marginBottom: 0,
},
},
deleteButton: {
position: 'absolute',
top: theme.spacing(1),
right: theme.spacing(1),
},
itemContent: {
paddingRight: theme.spacing(4), // Space for delete button
},
}))
// Default translations for array controls
const defaultTranslations = {
addTooltip: 'Add',
addAriaLabel: 'Add button',
removeTooltip: 'Delete',
removeAriaLabel: 'Delete button',
noDataMessage: 'No data',
}
// Simplified array item renderer - clean card layout
// eslint-disable-next-line react-refresh/only-export-components
const ArrayItem = ({
index,
path,
schema,
uischema,
uischemas,
rootSchema,
renderers,
cells,
enabled,
removeItems,
translations,
disableRemove,
}) => {
const classes = useStyles()
const childPath = composePaths(path, `${index}`)
const foundUISchema = useMemo(
() =>
findUISchema(
uischemas,
schema,
uischema.scope,
path,
undefined,
uischema,
rootSchema,
),
[uischemas, schema, path, uischema, rootSchema],
)
return (
<Box className={classes.arrayItem}>
{enabled && !disableRemove && (
<Tooltip
title={translations.removeTooltip}
className={classes.deleteButton}
>
<IconButton
onClick={() => removeItems(path, [index])()}
size="small"
aria-label={translations.removeAriaLabel}
>
<Delete fontSize="small" />
</IconButton>
</Tooltip>
)}
<Box className={classes.itemContent}>
<JsonFormsDispatch
enabled={enabled}
schema={schema}
uischema={foundUISchema}
path={childPath}
key={childPath}
renderers={renderers}
cells={cells}
/>
</Box>
</Box>
)
}
// Array toolbar with add button
// eslint-disable-next-line react-refresh/only-export-components
const ArrayToolbar = ({
label,
description,
enabled,
addItem,
path,
createDefault,
translations,
disableAdd,
}) => (
<Box mb={1}>
<Box display="flex" alignItems="center" justifyContent="space-between">
<Typography variant="h6">{label}</Typography>
{!disableAdd && (
<Tooltip
title={translations.addTooltip}
aria-label={translations.addAriaLabel}
>
<IconButton
onClick={addItem(path, createDefault())}
disabled={!enabled}
size="small"
>
<Add />
</IconButton>
</Tooltip>
)}
</Box>
{description && (
<Typography variant="caption" color="textSecondary">
{description}
</Typography>
)}
</Box>
)
const useArrayStyles = makeStyles((theme) => ({
container: {
marginBottom: theme.spacing(2),
},
}))
// Main array layout component - items always expanded
// eslint-disable-next-line react-refresh/only-export-components
const AlwaysExpandedArrayLayoutComponent = (props) => {
const arrayClasses = useArrayStyles()
const {
enabled,
data,
path,
schema,
uischema,
addItem,
removeItems,
renderers,
cells,
label,
description,
required,
rootSchema,
config,
uischemas,
disableAdd,
disableRemove,
} = props
const innerCreateDefaultValue = useCallback(
() => createDefaultValue(schema, rootSchema),
[schema, rootSchema],
)
const appliedUiSchemaOptions = merge({}, config, uischema.options)
const doDisableAdd = disableAdd || appliedUiSchemaOptions.disableAdd
const doDisableRemove = disableRemove || appliedUiSchemaOptions.disableRemove
const translations = defaultTranslations
return (
<div className={arrayClasses.container}>
<ArrayToolbar
translations={translations}
label={computeLabel(
label,
required,
appliedUiSchemaOptions.hideRequiredAsterisk,
)}
description={description}
path={path}
enabled={enabled}
addItem={addItem}
createDefault={innerCreateDefaultValue}
disableAdd={doDisableAdd}
/>
<div>
{data > 0 ? (
range(data).map((index) => (
<ArrayItem
key={index}
index={index}
path={path}
schema={schema}
uischema={uischema}
uischemas={uischemas}
rootSchema={rootSchema}
renderers={renderers}
cells={cells}
enabled={enabled}
removeItems={removeItems}
translations={translations}
disableRemove={doDisableRemove}
/>
))
) : (
<Typography color="textSecondary">
{translations.noDataMessage}
</Typography>
)}
</div>
</div>
)
}
// Wrap with JSONForms HOC
const WrappedArrayLayout = withJsonFormsArrayLayoutProps(
AlwaysExpandedArrayLayoutComponent,
)
// Custom tester that matches arrays but NOT enum arrays
// Enum arrays should be handled by MaterialEnumArrayRenderer (for checkboxes)
const isNonEnumArrayControl = (uischema, schema) => {
// First check if it matches our base conditions (object array or primitive array)
const baseCheck =
isObjectArrayWithNesting(uischema, schema) ||
isPrimitiveArrayControl(uischema, schema)
if (!baseCheck) {
return false
}
// Resolve the actual schema for this control using JSONForms utility
const rootSchema = schema
const resolved = Resolve.schema(rootSchema, uischema?.scope, rootSchema)
// Exclude enum arrays (uniqueItems + oneOf/enum) - let MaterialEnumArrayRenderer handle them
if (resolved?.uniqueItems && resolved?.items) {
const { items } = resolved
if (items.oneOf?.every((e) => e.const !== undefined) || items.enum) {
return false
}
}
return true
}
// Export as a renderer entry with high priority (5 > default 4)
// Matches both object arrays with nesting and primitive arrays, but NOT enum arrays
export const AlwaysExpandedArrayLayout = {
tester: rankWith(5, isNonEnumArrayControl),
renderer: WrappedArrayLayout,
}
+142 -129
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,
}
+257
View File
@@ -0,0 +1,257 @@
/* eslint-disable react-refresh/only-export-components */
import React, { useState } from 'react'
import {
rankWith,
isStringControl,
isIntegerControl,
isNumberControl,
isEnumControl,
isOneOfEnumControl,
and,
not,
or,
optionIs,
isDescriptionHidden,
} from '@jsonforms/core'
import {
withJsonFormsControlProps,
withJsonFormsEnumProps,
withJsonFormsOneOfEnumProps,
} from '@jsonforms/react'
import {
TextField,
FormControl,
FormHelperText,
InputLabel,
Select,
MenuItem,
} from '@material-ui/core'
import { makeStyles } from '@material-ui/core/styles'
import merge from 'lodash/merge'
const useStyles = makeStyles(
(theme) => ({
control: {
marginBottom: theme.spacing(2),
},
}),
{ name: 'NDOutlinedRenderers' },
)
/**
* Hook for common control state (focus, validation, description visibility)
* Tracks "touched" state to only show errors after the user has interacted with the field
*/
const useControlState = (props) => {
const { config, uischema, description, visible, errors } = props
const [isFocused, setIsFocused] = useState(false)
const [isTouched, setIsTouched] = useState(false)
const appliedUiSchemaOptions = merge({}, config, uischema?.options)
// errors is a string when there are validation errors, empty/undefined when valid
const hasErrors = errors && errors.length > 0
// Only show as invalid after the field has been touched (blurred)
const showError = isTouched && hasErrors
const showDescription = !isDescriptionHidden(
visible,
description,
isFocused,
appliedUiSchemaOptions.showUnfocusedDescription,
)
const helperText = showError ? errors : showDescription ? description : ''
const handleFocus = () => setIsFocused(true)
const handleBlur = () => {
setIsFocused(false)
setIsTouched(true)
}
return {
isFocused,
appliedUiSchemaOptions,
showError,
helperText,
handleFocus,
handleBlur,
}
}
/**
* Base outlined control component that uses TextField with outlined variant
* instead of the default Input component used by JSONForms 2.x
*/
const OutlinedControl = (props) => {
const classes = useStyles()
const {
data,
id,
enabled,
label,
visible,
type = 'text',
inputProps: extraInputProps = {},
onChange,
} = props
const {
appliedUiSchemaOptions,
showError,
helperText,
handleFocus,
handleBlur,
} = useControlState(props)
if (!visible) {
return null
}
return (
<TextField
id={id}
label={label}
type={type}
value={data ?? ''}
onChange={onChange}
onFocus={handleFocus}
onBlur={handleBlur}
disabled={!enabled}
autoFocus={appliedUiSchemaOptions.focus}
multiline={type === 'text' && appliedUiSchemaOptions.multi}
rows={appliedUiSchemaOptions.multi ? 3 : undefined}
variant="outlined"
fullWidth
size="small"
error={showError}
helperText={helperText}
inputProps={extraInputProps}
className={classes.control}
/>
)
}
// Text control wrapper
const OutlinedTextControl = (props) => {
const { path, handleChange, schema, config, uischema } = props
const appliedUiSchemaOptions = merge({}, config, uischema?.options)
const inputProps = {}
if (appliedUiSchemaOptions.restrict && schema?.maxLength) {
inputProps.maxLength = schema.maxLength
}
return (
<OutlinedControl
{...props}
type={appliedUiSchemaOptions.format === 'password' ? 'password' : 'text'}
inputProps={inputProps}
onChange={(ev) => handleChange(path, ev.target.value)}
/>
)
}
// Number control wrapper
const OutlinedNumberControl = (props) => {
const { path, handleChange, schema } = props
const { minimum, maximum } = schema || {}
const inputProps = {}
if (minimum !== undefined) inputProps.min = minimum
if (maximum !== undefined) inputProps.max = maximum
const handleNumberChange = (ev) => {
const value = ev.target.value
if (value === '') {
handleChange(path, undefined)
} else {
const numValue = Number(value)
if (!isNaN(numValue)) {
handleChange(path, numValue)
}
}
}
return (
<OutlinedControl
{...props}
type="number"
inputProps={inputProps}
onChange={handleNumberChange}
/>
)
}
// Enum/Select control wrapper
const OutlinedEnumControl = (props) => {
const classes = useStyles()
const { data, id, enabled, path, handleChange, options, label, visible } =
props
const {
appliedUiSchemaOptions,
showError,
helperText,
handleFocus,
handleBlur,
} = useControlState(props)
if (!visible) {
return null
}
return (
<FormControl
fullWidth
variant="outlined"
size="small"
error={showError}
className={classes.control}
>
<InputLabel id={`${id}-label`}>{label}</InputLabel>
<Select
labelId={`${id}-label`}
id={id}
value={data ?? ''}
onChange={(ev) => handleChange(path, ev.target.value)}
onFocus={handleFocus}
onBlur={handleBlur}
disabled={!enabled}
autoFocus={appliedUiSchemaOptions.focus}
label={label}
fullWidth
>
<MenuItem value="">
<em>None</em>
</MenuItem>
{options?.map((option) => (
<MenuItem key={option.value} value={option.value}>
{option.label}
</MenuItem>
))}
</Select>
{helperText && <FormHelperText>{helperText}</FormHelperText>}
</FormControl>
)
}
// Testers - higher rank than default to override default renderers
// Enum renderers have highest rank since isStringControl also matches enum fields
export const OutlinedEnumRenderer = {
tester: rankWith(5, isEnumControl),
renderer: withJsonFormsEnumProps(OutlinedEnumControl),
}
export const OutlinedOneOfEnumRenderer = {
tester: rankWith(5, isOneOfEnumControl),
renderer: withJsonFormsOneOfEnumProps(OutlinedEnumControl),
}
export const OutlinedTextRenderer = {
tester: rankWith(3, and(isStringControl, not(optionIs('format', 'radio')))),
renderer: withJsonFormsControlProps(OutlinedTextControl),
}
export const OutlinedNumberRenderer = {
tester: rankWith(3, or(isIntegerControl, isNumberControl)),
renderer: withJsonFormsControlProps(OutlinedNumberControl),
}
+40 -39
View File
@@ -33,9 +33,11 @@ const PluginShowLayout = () => {
const isSmall = useMediaQuery((theme) => theme.breakpoints.down('xs'))
useResourceRefresh('plugin')
const [configPairs, setConfigPairs] = useState([])
const [configData, setConfigData] = useState({})
const [configErrors, setConfigErrors] = useState([])
const [isDirty, setIsDirty] = useState(false)
const [lastRecordConfig, setLastRecordConfig] = useState(null)
const [isConfigInitialized, setIsConfigInitialized] = useState(false)
// Users permission state
const [selectedUsers, setSelectedUsers] = useState([])
@@ -49,41 +51,26 @@ const PluginShowLayout = () => {
const [lastRecordLibraries, setLastRecordLibraries] = useState(null)
const [lastRecordAllLibraries, setLastRecordAllLibraries] = useState(null)
// Convert JSON config to key-value pairs
const jsonToPairs = useCallback((jsonString) => {
if (!jsonString || jsonString.trim() === '') return []
// Parse JSON config to object
const jsonToObject = useCallback((jsonString) => {
if (!jsonString || jsonString.trim() === '') return {}
try {
const obj = JSON.parse(jsonString)
return Object.entries(obj).map(([key, value]) => ({
key,
value: typeof value === 'string' ? value : JSON.stringify(value),
}))
return JSON.parse(jsonString)
} catch {
return []
return {}
}
}, [])
// Convert key-value pairs to JSON config
const pairsToJson = useCallback((pairs) => {
if (pairs.length === 0) return ''
const obj = {}
pairs.forEach((pair) => {
if (pair.key.trim()) {
// Always store values as strings (backend expects map[string]string)
obj[pair.key] = pair.value
}
})
return JSON.stringify(obj)
}, [])
// Initialize/update config when record loads or changes (e.g., from SSE refresh)
React.useEffect(() => {
const recordConfig = record?.config || ''
if (record && recordConfig !== lastRecordConfig && !isDirty) {
setConfigPairs(jsonToPairs(recordConfig))
setConfigData(jsonToObject(recordConfig))
setLastRecordConfig(recordConfig)
// Reset initialization flag - AJV will apply defaults on first render
setIsConfigInitialized(false)
}
}, [record, lastRecordConfig, isDirty, jsonToPairs])
}, [record, lastRecordConfig, isDirty, jsonToObject])
// Initialize/update users permission state when record loads or changes
React.useEffect(() => {
@@ -131,10 +118,19 @@ const PluginShowLayout = () => {
}
}, [record, lastRecordLibraries, lastRecordAllLibraries, isDirty])
const handleConfigPairsChange = useCallback((newPairs) => {
setConfigPairs(newPairs)
setIsDirty(true)
}, [])
const handleConfigDataChange = useCallback(
(newData, errors) => {
setConfigData(newData)
setConfigErrors(errors || [])
// Skip marking dirty on initial onChange (when AJV applies defaults)
if (isConfigInitialized) {
setIsDirty(true)
} else {
setIsConfigInitialized(true)
}
},
[isConfigInitialized],
)
const handleSelectedUsersChange = useCallback((newSelectedUsers) => {
setSelectedUsers(newSelectedUsers)
@@ -184,18 +180,23 @@ const PluginShowLayout = () => {
const handleSaveConfig = useCallback(() => {
if (!record) return
const config = pairsToJson(configPairs)
const data = { config }
const parsedManifest = record.manifest ? JSON.parse(record.manifest) : null
const data = {}
// Only include config if the plugin has a config schema
if (parsedManifest?.config?.schema) {
data.config =
Object.keys(configData).length > 0 ? JSON.stringify(configData) : ''
}
// Include users data if users permission is present
const manifest = record.manifest ? JSON.parse(record.manifest) : null
if (manifest?.permissions?.users) {
if (parsedManifest?.permissions?.users) {
data.users = JSON.stringify(selectedUsers)
data.allUsers = allUsers
}
// Include libraries data if library permission is present
if (manifest?.permissions?.library) {
if (parsedManifest?.permissions?.library) {
data.libraries = JSON.stringify(selectedLibraries)
data.allLibraries = allLibraries
}
@@ -204,8 +205,7 @@ const PluginShowLayout = () => {
}, [
updatePlugin,
record,
configPairs,
pairsToJson,
configData,
selectedUsers,
allUsers,
selectedLibraries,
@@ -273,8 +273,9 @@ const PluginShowLayout = () => {
/>
<ConfigCard
configPairs={configPairs}
onConfigPairsChange={handleConfigPairsChange}
manifest={manifest}
configData={configData}
onConfigDataChange={handleConfigDataChange}
classes={classes}
translate={translate}
/>
@@ -303,7 +304,7 @@ const PluginShowLayout = () => {
color="primary"
startIcon={<MdSave />}
onClick={handleSaveConfig}
disabled={!isDirty || loading}
disabled={!isDirty || loading || configErrors.length > 0}
className={classes.saveButton}
>
{translate('ra.action.save')}
+241
View File
@@ -0,0 +1,241 @@
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,
}
+86
View File
@@ -0,0 +1,86 @@
import React from 'react'
import { describe, it, expect, vi } from 'vitest'
import { render } from '@testing-library/react'
import { ThemeProvider, createTheme } from '@material-ui/core/styles'
import { Provider } from 'react-redux'
import { createStore } from 'redux'
import { SchemaConfigEditor } from './SchemaConfigEditor'
const theme = createTheme()
// JSONForms requires Redux
const mockStore = createStore(() => ({}))
const renderWithProviders = (component) => {
return render(
<Provider store={mockStore}>
<ThemeProvider theme={theme}>{component}</ThemeProvider>
</Provider>,
)
}
describe('SchemaConfigEditor', () => {
const basicSchema = {
type: 'object',
properties: {
name: {
type: 'string',
title: 'Name',
},
enabled: {
type: 'boolean',
title: 'Enabled',
},
},
}
it('renders nothing when schema is null', () => {
const { container } = renderWithProviders(
<SchemaConfigEditor schema={null} data={{}} onChange={vi.fn()} />,
)
expect(container.firstChild).toBeNull()
})
it('renders the component wrapper with valid schema', () => {
const { container } = renderWithProviders(
<SchemaConfigEditor schema={basicSchema} data={{}} onChange={vi.fn()} />,
)
// Check that the wrapper div is rendered (class name is generated)
expect(
container.querySelector('[class*="NDSchemaConfigEditor-root"]'),
).toBeTruthy()
})
it('calls onChange on initial render', () => {
const onChange = vi.fn()
renderWithProviders(
<SchemaConfigEditor
schema={basicSchema}
data={{ name: 'Test' }}
onChange={onChange}
/>,
)
// JSONForms calls onChange on initial render with initial state
expect(onChange).toHaveBeenCalled()
})
it('passes data and errors to onChange callback', () => {
const onChange = vi.fn()
const initialData = { name: 'Test Value' }
renderWithProviders(
<SchemaConfigEditor
schema={basicSchema}
data={initialData}
onChange={onChange}
/>,
)
// Check that onChange was called with data and errors
expect(onChange).toHaveBeenCalledWith(
expect.objectContaining({ name: 'Test Value' }),
expect.any(Array),
)
})
})
+10 -1
View File
@@ -22,6 +22,15 @@ const useStyles = makeStyles((theme) => ({
theme.palette.success?.main || theme.palette.primary.main,
},
},
errorSwitch: {
'& .MuiSwitch-thumb': {
backgroundColor: theme.palette.warning.main,
},
'& .MuiSwitch-track': {
backgroundColor: theme.palette.warning.light,
opacity: 0.7,
},
},
}))
/**
@@ -146,7 +155,7 @@ const ToggleEnabledSwitch = ({
checked={record?.enabled ?? false}
onClick={handleClick}
disabled={isDisabled}
className={classes.enabledSwitch}
className={isDisabled ? classes.errorSwitch : classes.enabledSwitch}
size={size}
color="primary"
/>