diff --git a/plugins/examples/discord-rich-presence-rs/manifest.json b/plugins/examples/discord-rich-presence-rs/manifest.json
index d9206976..4cf64b55 100644
--- a/plugins/examples/discord-rich-presence-rs/manifest.json
+++ b/plugins/examples/discord-rich-presence-rs/manifest.json
@@ -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",
diff --git a/plugins/examples/discord-rich-presence/manifest.json b/plugins/examples/discord-rich-presence/manifest.json
index 403cb917..ac8eec01 100644
--- a/plugins/examples/discord-rich-presence/manifest.json
+++ b/plugins/examples/discord-rich-presence/manifest.json
@@ -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",
diff --git a/ui/src/plugin/AlwaysExpandedArrayLayout.jsx b/ui/src/plugin/AlwaysExpandedArrayLayout.jsx
deleted file mode 100644
index 2c833a1f..00000000
--- a/ui/src/plugin/AlwaysExpandedArrayLayout.jsx
+++ /dev/null
@@ -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 (
-
- {enabled && !disableRemove && (
-
- removeItems(path, [index])()}
- size="small"
- aria-label={translations.removeAriaLabel}
- >
-
-
-
- )}
-
-
-
-
- )
-}
-
-// Array toolbar with add button
-// eslint-disable-next-line react-refresh/only-export-components
-const ArrayToolbar = ({
- label,
- description,
- enabled,
- addItem,
- path,
- createDefault,
- translations,
- disableAdd,
-}) => (
-
-
- {label}
- {!disableAdd && (
-
-
-
-
-
- )}
-
- {description && (
-
- {description}
-
- )}
-
-)
-
-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 (
-
-
-
- {data > 0 ? (
- range(data).map((index) => (
-
- ))
- ) : (
-
- {translations.noDataMessage}
-
- )}
-
-
- )
-}
-
-// 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,
-}
diff --git a/ui/src/plugin/ConfigCard.jsx b/ui/src/plugin/ConfigCard.jsx
index 4e5bd629..d9815aa3 100644
--- a/ui/src/plugin/ConfigCard.jsx
+++ b/ui/src/plugin/ConfigCard.jsx
@@ -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 = ({
)}
-
+ 0 ? 0 : 2}>
+
+
)
diff --git a/ui/src/plugin/OutlinedRenderers.jsx b/ui/src/plugin/OutlinedRenderers.jsx
index 5156038f..8020a5e4 100644
--- a/ui/src/plugin/OutlinedRenderers.jsx
+++ b/ui/src/plugin/OutlinedRenderers.jsx
@@ -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,
@@ -185,8 +178,17 @@ const OutlinedNumberControl = (props) => {
// Enum/Select control wrapper
const OutlinedEnumControl = (props) => {
const classes = useStyles()
- const { data, id, enabled, path, handleChange, options, label, visible } =
- props
+ const {
+ data,
+ id,
+ enabled,
+ path,
+ handleChange,
+ options,
+ label,
+ visible,
+ required,
+ } = props
const {
appliedUiSchemaOptions,
showError,
@@ -212,7 +214,12 @@ const OutlinedEnumControl = (props) => {
labelId={`${id}-label`}
id={id}
value={data ?? ''}
- onChange={(ev) => handleChange(path, ev.target.value)}
+ onChange={(ev) => {
+ handleChange(
+ path,
+ ev.target.value === '' ? undefined : ev.target.value,
+ )
+ }}
onFocus={handleFocus}
onBlur={handleBlur}
disabled={!enabled}
@@ -220,9 +227,11 @@ const OutlinedEnumControl = (props) => {
label={label}
fullWidth
>
-
+ {!required && (
+
+ )}
{options?.map((option) => (