f1e75c40dc
* 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>
328 lines
9.5 KiB
React
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
|