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:
@@ -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')}
|
||||
|
||||
Reference in New Issue
Block a user