Files
navidrome/ui/src/plugin/PluginShow.jsx
T
Deluan Quintão f1e75c40dc 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>
2026-01-19 20:51:00 -05:00

328 lines
9.5 KiB
React

import React, { useState, useCallback, useMemo } from 'react'
import {
ShowContextProvider,
useShowController,
useShowContext,
useTranslate,
useUpdate,
useNotify,
useRefresh,
Title as RaTitle,
Loading,
} from 'react-admin'
import { Box, useMediaQuery, Button } from '@material-ui/core'
import { MdSave } from 'react-icons/md'
import Alert from '@material-ui/lab/Alert'
import { Title, useResourceRefresh } from '../common'
import { usePluginShowStyles } from './styles.js'
import { ErrorSection } from './ErrorSection'
import { StatusCard } from './StatusCard'
import { InfoCard } from './InfoCard'
import { ManifestSection } from './ManifestSection'
import { ConfigCard } from './ConfigCard'
import { UsersPermissionCard } from './UsersPermissionCard'
import { LibraryPermissionCard } from './LibraryPermissionCard'
// Main show layout component
const PluginShowLayout = () => {
const { record, isPending, error } = useShowContext()
const classes = usePluginShowStyles()
const translate = useTranslate()
const notify = useNotify()
const refresh = useRefresh()
const isSmall = useMediaQuery((theme) => theme.breakpoints.down('xs'))
useResourceRefresh('plugin')
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([])
const [allUsers, setAllUsers] = useState(false)
const [lastRecordUsers, setLastRecordUsers] = useState(null)
const [lastRecordAllUsers, setLastRecordAllUsers] = useState(null)
// Libraries permission state
const [selectedLibraries, setSelectedLibraries] = useState([])
const [allLibraries, setAllLibraries] = useState(false)
const [lastRecordLibraries, setLastRecordLibraries] = useState(null)
const [lastRecordAllLibraries, setLastRecordAllLibraries] = useState(null)
// Parse JSON config to object
const jsonToObject = useCallback((jsonString) => {
if (!jsonString || jsonString.trim() === '') return {}
try {
return JSON.parse(jsonString)
} catch {
return {}
}
}, [])
// Initialize/update config when record loads or changes (e.g., from SSE refresh)
React.useEffect(() => {
const recordConfig = record?.config || ''
if (record && recordConfig !== lastRecordConfig && !isDirty) {
setConfigData(jsonToObject(recordConfig))
setLastRecordConfig(recordConfig)
// Reset initialization flag - AJV will apply defaults on first render
setIsConfigInitialized(false)
}
}, [record, lastRecordConfig, isDirty, jsonToObject])
// Initialize/update users permission state when record loads or changes
React.useEffect(() => {
if (record && !isDirty) {
const recordUsers = record.users || ''
const recordAllUsers = record.allUsers || false
if (
recordUsers !== lastRecordUsers ||
recordAllUsers !== lastRecordAllUsers
) {
try {
setSelectedUsers(recordUsers ? JSON.parse(recordUsers) : [])
} catch {
setSelectedUsers([])
}
setAllUsers(recordAllUsers)
setLastRecordUsers(recordUsers)
setLastRecordAllUsers(recordAllUsers)
}
}
}, [record, lastRecordUsers, lastRecordAllUsers, isDirty])
// Initialize/update libraries permission state when record loads or changes
React.useEffect(() => {
if (record && !isDirty) {
const recordLibraries = record.libraries || ''
const recordAllLibraries = record.allLibraries || false
if (
recordLibraries !== lastRecordLibraries ||
recordAllLibraries !== lastRecordAllLibraries
) {
try {
setSelectedLibraries(
recordLibraries ? JSON.parse(recordLibraries) : [],
)
} catch {
setSelectedLibraries([])
}
setAllLibraries(recordAllLibraries)
setLastRecordLibraries(recordLibraries)
setLastRecordAllLibraries(recordAllLibraries)
}
}
}, [record, lastRecordLibraries, lastRecordAllLibraries, isDirty])
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)
setIsDirty(true)
}, [])
const handleAllUsersChange = useCallback((newAllUsers) => {
setAllUsers(newAllUsers)
setIsDirty(true)
}, [])
const handleSelectedLibrariesChange = useCallback((newSelectedLibraries) => {
setSelectedLibraries(newSelectedLibraries)
setIsDirty(true)
}, [])
const handleAllLibrariesChange = useCallback((newAllLibraries) => {
setAllLibraries(newAllLibraries)
setIsDirty(true)
}, [])
const [updatePlugin, { loading }] = useUpdate(
'plugin',
record?.id,
{},
record,
{
undoable: false,
onSuccess: () => {
refresh()
setIsDirty(false)
setLastRecordConfig(null) // Reset to reinitialize from server
setLastRecordUsers(null)
setLastRecordAllUsers(null)
setLastRecordLibraries(null)
setLastRecordAllLibraries(null)
notify('resources.plugin.notifications.updated', 'info')
},
onFailure: (err) => {
notify(
err?.message || 'resources.plugin.notifications.error',
'warning',
)
},
},
)
const handleSaveConfig = useCallback(() => {
if (!record) return
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
if (parsedManifest?.permissions?.users) {
data.users = JSON.stringify(selectedUsers)
data.allUsers = allUsers
}
// Include libraries data if library permission is present
if (parsedManifest?.permissions?.library) {
data.libraries = JSON.stringify(selectedLibraries)
data.allLibraries = allLibraries
}
updatePlugin('plugin', record.id, data, record)
}, [
updatePlugin,
record,
configData,
selectedUsers,
allUsers,
selectedLibraries,
allLibraries,
])
// Parse manifest
const { manifest, manifestJson } = useMemo(() => {
if (!record?.manifest) return { manifest: null, manifestJson: '' }
try {
const parsed = JSON.parse(record.manifest)
return { manifest: parsed, manifestJson: JSON.stringify(parsed, null, 2) }
} catch {
return { manifest: null, manifestJson: record.manifest }
}
}, [record?.manifest])
// Handle loading state
if (isPending) {
return <Loading />
}
// Handle error state
if (error) {
return (
<Alert severity="error">{translate('ra.notification.http_error')}</Alert>
)
}
// Handle missing record
if (!record) {
return null
}
return (
<>
<RaTitle
title={
<Title
subTitle={`${translate('resources.plugin.name', { smart_count: 1 })} "${record.id}"`}
/>
}
/>
<Box className={classes.root}>
<ErrorSection error={record.lastError} translate={translate} />
<StatusCard
classes={classes}
translate={translate}
manifest={manifest}
/>
<InfoCard
record={record}
manifest={manifest}
classes={classes}
translate={translate}
isSmall={isSmall}
/>
<ManifestSection
manifestJson={manifestJson}
classes={classes}
translate={translate}
/>
<ConfigCard
manifest={manifest}
configData={configData}
onConfigDataChange={handleConfigDataChange}
classes={classes}
translate={translate}
/>
<UsersPermissionCard
manifest={manifest}
classes={classes}
selectedUsers={selectedUsers}
allUsers={allUsers}
onSelectedUsersChange={handleSelectedUsersChange}
onAllUsersChange={handleAllUsersChange}
/>
<LibraryPermissionCard
manifest={manifest}
classes={classes}
selectedLibraries={selectedLibraries}
allLibraries={allLibraries}
onSelectedLibrariesChange={handleSelectedLibrariesChange}
onAllLibrariesChange={handleAllLibrariesChange}
/>
<Box display="flex" justifyContent="flex-end">
<Button
variant="contained"
color="primary"
startIcon={<MdSave />}
onClick={handleSaveConfig}
disabled={!isDirty || loading || configErrors.length > 0}
className={classes.saveButton}
>
{translate('ra.action.save')}
</Button>
</Box>
</Box>
</>
)
}
const PluginShow = (props) => {
const controllerProps = useShowController(props)
return (
<ShowContextProvider value={controllerProps}>
<PluginShowLayout />
</ShowContextProvider>
)
}
export default PluginShow