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 > - - None - + {!required && ( + + None + + )} {options?.map((option) => ( {option.label} diff --git a/ui/src/plugin/SchemaConfigEditor.jsx b/ui/src/plugin/SchemaConfigEditor.jsx index 58259f77..096bfeb9 100644 --- a/ui/src/plugin/SchemaConfigEditor.jsx +++ b/ui/src/plugin/SchemaConfigEditor.jsx @@ -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, @@ -43,7 +42,7 @@ SchemaErrorBoundary.propTypes = { // 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, + useDefaults: false, allErrors: true, verbose: true, jsonPointers: true, @@ -135,7 +134,6 @@ const customRenderers = [ OutlinedNumberRenderer, OutlinedEnumRenderer, OutlinedOneOfEnumRenderer, - AlwaysExpandedArrayLayout, // Then all the standard material renderers ...materialRenderers, ]