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
+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')}