Compare commits

..

5 Commits

Author SHA1 Message Date
Deluan
678efed9b3 fix(ui): enhance error handling by returning field info and path in validation errors
Signed-off-by: Deluan <deluan@navidrome.org>
2026-01-20 18:19:55 -05:00
Deluan
f3532ec9e6 fix(ui): remove "None" MenuItem from OutlinedEnumControl
Signed-off-by: Deluan <deluan@navidrome.org>
2026-01-20 18:19:55 -05:00
Deluan
fa016528c4 fix(ui): simplify error handling in control state hook
Signed-off-by: Deluan <deluan@navidrome.org>
2026-01-20 18:19:55 -05:00
Deluan
6a57fd71cf fix(plugins): enforce minimum user tokens and require users field
Signed-off-by: Deluan <deluan@navidrome.org>
2026-01-20 18:19:55 -05:00
Deluan
2fb383b58a fix(ui): use stock array renderer for plugins config form
Signed-off-by: Deluan <deluan@navidrome.org>
2026-01-20 18:19:55 -05:00
6 changed files with 41 additions and 370 deletions

View File

@@ -42,7 +42,7 @@
"type": "array",
"title": "User Tokens",
"description": "Discord tokens for each Navidrome user. WARNING: Store tokens securely!",
"default": [{}],
"minItems": 1,
"items": {
"type": "object",
"properties": {
@@ -63,7 +63,7 @@
}
}
},
"required": ["clientid"]
"required": ["clientid", "users"]
},
"uiSchema": {
"type": "VerticalLayout",

View File

@@ -46,7 +46,7 @@
"type": "array",
"title": "User Tokens",
"description": "Discord tokens for each Navidrome user. WARNING: Store tokens securely!",
"default": [{}],
"minItems": 1,
"items": {
"type": "object",
"properties": {
@@ -67,7 +67,7 @@
}
}
},
"required": ["clientid"]
"required": ["clientid", "users"]
},
"uiSchema": {
"type": "VerticalLayout",

View File

@@ -1,276 +0,0 @@
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,
}

View File

@@ -4,76 +4,37 @@ 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) => {
// Format error with field title and full path for nested fields
const formatError = (error, schema) => {
// Get path parts from various error formats
const rawPath =
error.dataPath || error.property || error.instancePath?.replace(/\//g, '.')
const parts = rawPath?.split('.').filter(Boolean) || []
// Navigate schema to find field title, build bracket-notation path
let currentSchema = schema
let fieldName = parts[parts.length - 1] // Default to last part
let fieldName = parts[parts.length - 1]
const pathParts = []
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
pathParts.push(`[${part}]`)
currentSchema = currentSchema?.items
} else {
fieldName = currentSchema?.properties?.[part]?.title || part
pathParts.push(part)
currentSchema = currentSchema?.properties?.[part]
}
}
return fieldName
}
const path = pathParts.join('.').replace(/\.\[/g, '[')
const isNested = path.includes('[') || path.includes('.')
// Replace property name in message with full path for nested fields
const message = isNested
? error.message.replace(/'[^']+'\s*$/, `'${path}'`)
: error.message
// 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
return { fieldName, message }
}
export const ConfigCard = ({
@@ -99,14 +60,10 @@ export const ConfigCard = ({
// 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,
}))
if (!hasConfigSchema) return []
return validationErrors.map((error) =>
formatError(error, manifest.config.schema),
)
}, [validationErrors, manifest, hasConfigSchema])
if (!hasConfigSchema) {
@@ -139,12 +96,14 @@ export const ConfigCard = ({
</Box>
)}
<SchemaConfigEditor
schema={schema}
uiSchema={uiSchema}
data={configData}
onChange={handleChange}
/>
<Box mt={formattedErrors.length > 0 ? 0 : 2}>
<SchemaConfigEditor
schema={schema}
uiSchema={uiSchema}
data={configData}
onChange={handleChange}
/>
</Box>
</CardContent>
</Card>
)

View File

@@ -40,18 +40,14 @@ const useStyles = makeStyles(
/**
* 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 showError = errors && errors.length > 0
const showDescription = !isDescriptionHidden(
visible,
@@ -63,10 +59,7 @@ const useControlState = (props) => {
const helperText = showError ? errors : showDescription ? description : ''
const handleFocus = () => setIsFocused(true)
const handleBlur = () => {
setIsFocused(false)
setIsTouched(true)
}
const handleBlur = () => setIsFocused(false)
return {
isFocused,
@@ -220,9 +213,6 @@ const OutlinedEnumControl = (props) => {
label={label}
fullWidth
>
<MenuItem value="">
<em>None</em>
</MenuItem>
{options?.map((option) => (
<MenuItem key={option.value} value={option.value}>
{option.label}

View File

@@ -6,7 +6,6 @@ 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,
@@ -135,7 +134,6 @@ const customRenderers = [
OutlinedNumberRenderer,
OutlinedEnumRenderer,
OutlinedOneOfEnumRenderer,
AlwaysExpandedArrayLayout,
// Then all the standard material renderers
...materialRenderers,
]