mirror of
https://github.com/navidrome/navidrome.git
synced 2026-08-01 07:21:17 +00:00
* 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>
401 lines
10 KiB
JavaScript
401 lines
10 KiB
JavaScript
import React, { useState } from 'react'
|
||
import TextField from '@material-ui/core/TextField'
|
||
import Checkbox from '@material-ui/core/Checkbox'
|
||
import CheckBoxIcon from '@material-ui/icons/CheckBox'
|
||
import CheckBoxOutlineBlankIcon from '@material-ui/icons/CheckBoxOutlineBlank'
|
||
import {
|
||
List,
|
||
ListItem,
|
||
ListItemIcon,
|
||
ListItemText,
|
||
Typography,
|
||
Box,
|
||
InputAdornment,
|
||
IconButton,
|
||
} from '@material-ui/core'
|
||
import AddIcon from '@material-ui/icons/Add'
|
||
import { useGetList, useTranslate } from 'react-admin'
|
||
import PropTypes from 'prop-types'
|
||
import { isWritable } from '../common'
|
||
import { makeStyles } from '@material-ui/core'
|
||
|
||
const useStyles = makeStyles((theme) => ({
|
||
root: {
|
||
width: '100%',
|
||
height: '100%',
|
||
display: 'flex',
|
||
flexDirection: 'column',
|
||
},
|
||
searchField: {
|
||
marginBottom: theme.spacing(2),
|
||
width: '100%',
|
||
flexShrink: 0,
|
||
},
|
||
playlistList: {
|
||
flex: 1,
|
||
minHeight: 0,
|
||
overflow: 'auto',
|
||
border: `1px solid ${theme.palette.divider}`,
|
||
borderRadius: theme.shape.borderRadius,
|
||
backgroundColor: theme.palette.background.paper,
|
||
},
|
||
listItem: {
|
||
paddingTop: 0,
|
||
paddingBottom: 0,
|
||
},
|
||
createIcon: {
|
||
fontSize: '1.25rem',
|
||
margin: '9px',
|
||
},
|
||
selectedPlaylistsContainer: {
|
||
marginTop: theme.spacing(2),
|
||
flexShrink: 0,
|
||
maxHeight: '30%',
|
||
overflow: 'auto',
|
||
},
|
||
selectedPlaylist: {
|
||
display: 'inline-flex',
|
||
alignItems: 'center',
|
||
margin: theme.spacing(0.5),
|
||
padding: theme.spacing(0.5, 1),
|
||
backgroundColor: theme.palette.primary.main,
|
||
color: theme.palette.primary.contrastText,
|
||
borderRadius: theme.shape.borderRadius,
|
||
fontSize: '0.875rem',
|
||
},
|
||
removeButton: {
|
||
marginLeft: theme.spacing(0.5),
|
||
padding: 2,
|
||
color: 'inherit',
|
||
},
|
||
emptyMessage: {
|
||
padding: theme.spacing(2),
|
||
textAlign: 'center',
|
||
color: theme.palette.text.secondary,
|
||
},
|
||
}))
|
||
|
||
const PlaylistSearchField = ({
|
||
searchText,
|
||
onSearchChange,
|
||
onCreateNew,
|
||
onKeyDown,
|
||
canCreateNew,
|
||
}) => {
|
||
const classes = useStyles()
|
||
const translate = useTranslate()
|
||
|
||
return (
|
||
<TextField
|
||
autoFocus
|
||
variant="outlined"
|
||
className={classes.searchField}
|
||
label={translate('resources.playlist.fields.name')}
|
||
value={searchText}
|
||
onChange={(e) => onSearchChange(e.target.value)}
|
||
onKeyDown={onKeyDown}
|
||
placeholder={translate('resources.playlist.actions.searchOrCreate')}
|
||
InputProps={{
|
||
endAdornment: canCreateNew && (
|
||
<InputAdornment position="end">
|
||
<IconButton
|
||
onClick={onCreateNew}
|
||
size="small"
|
||
title={translate('resources.playlist.actions.addNewPlaylist', {
|
||
name: searchText,
|
||
})}
|
||
>
|
||
<AddIcon />
|
||
</IconButton>
|
||
</InputAdornment>
|
||
),
|
||
}}
|
||
/>
|
||
)
|
||
}
|
||
|
||
const EmptyPlaylistMessage = ({ searchText, canCreateNew }) => {
|
||
const classes = useStyles()
|
||
const translate = useTranslate()
|
||
|
||
return (
|
||
<div className={classes.emptyMessage}>
|
||
<Typography variant="body2">
|
||
{searchText
|
||
? translate('resources.playlist.message.noPlaylistsFound')
|
||
: translate('resources.playlist.message.noPlaylists')}
|
||
</Typography>
|
||
{canCreateNew && (
|
||
<Typography variant="body2" color="primary">
|
||
{translate('resources.playlist.actions.pressEnterToCreate')}
|
||
</Typography>
|
||
)}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
const PlaylistListItem = ({ playlist, isSelected, onToggle }) => {
|
||
const classes = useStyles()
|
||
|
||
return (
|
||
<ListItem
|
||
className={classes.listItem}
|
||
button
|
||
onClick={() => onToggle(playlist)}
|
||
dense
|
||
>
|
||
<ListItemIcon>
|
||
<Checkbox
|
||
icon={<CheckBoxOutlineBlankIcon fontSize="small" />}
|
||
checkedIcon={<CheckBoxIcon fontSize="small" />}
|
||
checked={isSelected}
|
||
tabIndex={-1}
|
||
disableRipple
|
||
/>
|
||
</ListItemIcon>
|
||
<ListItemText primary={playlist.name} />
|
||
</ListItem>
|
||
)
|
||
}
|
||
|
||
const CreatePlaylistItem = ({ searchText, onCreateNew }) => {
|
||
const classes = useStyles()
|
||
const translate = useTranslate()
|
||
|
||
return (
|
||
<ListItem className={classes.listItem} button onClick={onCreateNew} dense>
|
||
<ListItemIcon>
|
||
<AddIcon className={classes.createIcon} />
|
||
</ListItemIcon>
|
||
<ListItemText
|
||
primary={translate('resources.playlist.actions.addNewPlaylist', {
|
||
name: searchText,
|
||
})}
|
||
/>
|
||
</ListItem>
|
||
)
|
||
}
|
||
|
||
const PlaylistList = ({
|
||
filteredOptions,
|
||
selectedPlaylists,
|
||
onPlaylistToggle,
|
||
searchText,
|
||
canCreateNew,
|
||
onCreateNew,
|
||
}) => {
|
||
const classes = useStyles()
|
||
|
||
const isPlaylistSelected = (playlist) =>
|
||
selectedPlaylists.some((p) => p.id === playlist.id)
|
||
|
||
return (
|
||
<List className={classes.playlistList}>
|
||
{filteredOptions.length === 0 ? (
|
||
<EmptyPlaylistMessage
|
||
searchText={searchText}
|
||
canCreateNew={canCreateNew}
|
||
/>
|
||
) : (
|
||
filteredOptions.map((playlist) => (
|
||
<PlaylistListItem
|
||
key={playlist.id}
|
||
playlist={playlist}
|
||
isSelected={isPlaylistSelected(playlist)}
|
||
onToggle={onPlaylistToggle}
|
||
/>
|
||
))
|
||
)}
|
||
{canCreateNew && filteredOptions.length > 0 && (
|
||
<CreatePlaylistItem searchText={searchText} onCreateNew={onCreateNew} />
|
||
)}
|
||
</List>
|
||
)
|
||
}
|
||
|
||
const SelectedPlaylistChip = ({ playlist, onRemove }) => {
|
||
const classes = useStyles()
|
||
const translate = useTranslate()
|
||
|
||
return (
|
||
<span className={classes.selectedPlaylist}>
|
||
{playlist.name}
|
||
<IconButton
|
||
className={classes.removeButton}
|
||
size="small"
|
||
onClick={() => onRemove(playlist)}
|
||
title={translate('resources.playlist.actions.removeFromSelection')}
|
||
>
|
||
{'×'}
|
||
</IconButton>
|
||
</span>
|
||
)
|
||
}
|
||
|
||
const SelectedPlaylistsDisplay = ({ selectedPlaylists, onRemoveSelected }) => {
|
||
const classes = useStyles()
|
||
const translate = useTranslate()
|
||
|
||
if (selectedPlaylists.length === 0) {
|
||
return null
|
||
}
|
||
|
||
return (
|
||
<Box className={classes.selectedPlaylistsContainer}>
|
||
<Box>
|
||
{selectedPlaylists.map((playlist, index) => (
|
||
<SelectedPlaylistChip
|
||
key={playlist.id || `new-${index}`}
|
||
playlist={playlist}
|
||
onRemove={onRemoveSelected}
|
||
/>
|
||
))}
|
||
</Box>
|
||
</Box>
|
||
)
|
||
}
|
||
|
||
export const SelectPlaylistInput = ({ onChange }) => {
|
||
const classes = useStyles()
|
||
const [searchText, setSearchText] = useState('')
|
||
const [selectedPlaylists, setSelectedPlaylists] = useState([])
|
||
|
||
const { ids, data } = useGetList(
|
||
'playlist',
|
||
{ page: 1, perPage: -1 },
|
||
{ field: 'name', order: 'ASC' },
|
||
{ smart: false },
|
||
)
|
||
|
||
const options =
|
||
ids &&
|
||
ids.map((id) => data[id]).filter((option) => isWritable(option.ownerId))
|
||
|
||
// Filter playlists based on search text
|
||
const filteredOptions =
|
||
options?.filter((option) =>
|
||
option.name.toLowerCase().includes(searchText.toLowerCase()),
|
||
) || []
|
||
|
||
const handlePlaylistToggle = (playlist) => {
|
||
const isSelected = selectedPlaylists.some((p) => p.id === playlist.id)
|
||
let newSelection
|
||
|
||
if (isSelected) {
|
||
newSelection = selectedPlaylists.filter((p) => p.id !== playlist.id)
|
||
} else {
|
||
newSelection = [...selectedPlaylists, playlist]
|
||
}
|
||
|
||
setSelectedPlaylists(newSelection)
|
||
onChange(newSelection)
|
||
}
|
||
|
||
const handleRemoveSelected = (playlistToRemove) => {
|
||
const newSelection = selectedPlaylists.filter(
|
||
(p) => p.id !== playlistToRemove.id,
|
||
)
|
||
setSelectedPlaylists(newSelection)
|
||
onChange(newSelection)
|
||
}
|
||
|
||
const handleCreateNew = () => {
|
||
if (searchText.trim()) {
|
||
const newPlaylist = { name: searchText.trim() }
|
||
const newSelection = [...selectedPlaylists, newPlaylist]
|
||
setSelectedPlaylists(newSelection)
|
||
onChange(newSelection)
|
||
setSearchText('')
|
||
}
|
||
}
|
||
|
||
const handleKeyDown = (e) => {
|
||
if (e.key === 'Enter' && searchText.trim()) {
|
||
e.preventDefault()
|
||
handleCreateNew()
|
||
}
|
||
}
|
||
|
||
const canCreateNew = Boolean(
|
||
searchText.trim() &&
|
||
!filteredOptions.some(
|
||
(option) => option.name.toLowerCase() === searchText.toLowerCase().trim(),
|
||
) &&
|
||
!selectedPlaylists.some((p) => p.name === searchText.trim()),
|
||
)
|
||
|
||
return (
|
||
<div className={classes.root}>
|
||
<PlaylistSearchField
|
||
searchText={searchText}
|
||
onSearchChange={setSearchText}
|
||
onCreateNew={handleCreateNew}
|
||
onKeyDown={handleKeyDown}
|
||
canCreateNew={canCreateNew}
|
||
/>
|
||
|
||
<PlaylistList
|
||
filteredOptions={filteredOptions}
|
||
selectedPlaylists={selectedPlaylists}
|
||
onPlaylistToggle={handlePlaylistToggle}
|
||
searchText={searchText}
|
||
canCreateNew={canCreateNew}
|
||
onCreateNew={handleCreateNew}
|
||
/>
|
||
|
||
<SelectedPlaylistsDisplay
|
||
selectedPlaylists={selectedPlaylists}
|
||
onRemoveSelected={handleRemoveSelected}
|
||
/>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
SelectPlaylistInput.propTypes = {
|
||
onChange: PropTypes.func.isRequired,
|
||
}
|
||
|
||
// PropTypes for sub-components
|
||
PlaylistSearchField.propTypes = {
|
||
searchText: PropTypes.string.isRequired,
|
||
onSearchChange: PropTypes.func.isRequired,
|
||
onCreateNew: PropTypes.func.isRequired,
|
||
onKeyDown: PropTypes.func.isRequired,
|
||
canCreateNew: PropTypes.bool.isRequired,
|
||
}
|
||
|
||
EmptyPlaylistMessage.propTypes = {
|
||
searchText: PropTypes.string.isRequired,
|
||
canCreateNew: PropTypes.bool.isRequired,
|
||
}
|
||
|
||
PlaylistListItem.propTypes = {
|
||
playlist: PropTypes.object.isRequired,
|
||
isSelected: PropTypes.bool.isRequired,
|
||
onToggle: PropTypes.func.isRequired,
|
||
}
|
||
|
||
CreatePlaylistItem.propTypes = {
|
||
searchText: PropTypes.string.isRequired,
|
||
onCreateNew: PropTypes.func.isRequired,
|
||
}
|
||
|
||
PlaylistList.propTypes = {
|
||
filteredOptions: PropTypes.array.isRequired,
|
||
selectedPlaylists: PropTypes.array.isRequired,
|
||
onPlaylistToggle: PropTypes.func.isRequired,
|
||
searchText: PropTypes.string.isRequired,
|
||
canCreateNew: PropTypes.bool.isRequired,
|
||
onCreateNew: PropTypes.func.isRequired,
|
||
}
|
||
|
||
SelectedPlaylistChip.propTypes = {
|
||
playlist: PropTypes.object.isRequired,
|
||
onRemove: PropTypes.func.isRequired,
|
||
}
|
||
|
||
SelectedPlaylistsDisplay.propTypes = {
|
||
selectedPlaylists: PropTypes.array.isRequired,
|
||
onRemoveSelected: PropTypes.func.isRequired,
|
||
}
|