fix(ui): add button is covered when adding to a playlist (#4156)
* refactor: fix SelectPlaylistInput layout and improve readability - Replace dropdown with fixed list to prevent button overlay - Break down into smaller focused components - Add comprehensive test coverage - Reduce spacing for compact layout * refactor: update playlist input translations Signed-off-by: Deluan <deluan@navidrome.org> * fix: format code with prettier - Fix formatting issues in AddToPlaylistDialog.test.jsx --------- Signed-off-by: Deluan <deluan@navidrome.org>
This commit is contained in:
@@ -197,11 +197,17 @@
|
|||||||
"export": "Exportar",
|
"export": "Exportar",
|
||||||
"makePublic": "Pública",
|
"makePublic": "Pública",
|
||||||
"makePrivate": "Pessoal",
|
"makePrivate": "Pessoal",
|
||||||
"saveQueue": "Salvar fila em nova Playlist"
|
"saveQueue": "Salvar fila em nova Playlist",
|
||||||
|
"searchOrCreate": "Buscar playlists ou criar nova...",
|
||||||
|
"pressEnterToCreate": "Pressione Enter para criar nova playlist",
|
||||||
|
"removeFromSelection": "Remover da seleção",
|
||||||
|
"removeSymbol": "×"
|
||||||
},
|
},
|
||||||
"message": {
|
"message": {
|
||||||
"duplicate_song": "Adicionar músicas duplicadas",
|
"duplicate_song": "Adicionar músicas duplicadas",
|
||||||
"song_exist": "Algumas destas músicas já existem na playlist. Você quer adicionar as duplicadas ou ignorá-las?"
|
"song_exist": "Algumas destas músicas já existem na playlist. Você quer adicionar as duplicadas ou ignorá-las?",
|
||||||
|
"noPlaylistsFound": "Nenhuma playlist encontrada",
|
||||||
|
"noPlaylists": "Nenhuma playlist disponível"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"radio": {
|
"radio": {
|
||||||
|
|||||||
@@ -88,12 +88,18 @@ describe('AddToPlaylistDialog', () => {
|
|||||||
|
|
||||||
createTestUtils(mockDataProvider)
|
createTestUtils(mockDataProvider)
|
||||||
|
|
||||||
|
// Filter to see sample playlists
|
||||||
let textBox = screen.getByRole('textbox')
|
let textBox = screen.getByRole('textbox')
|
||||||
fireEvent.change(textBox, { target: { value: 'sample' } })
|
fireEvent.change(textBox, { target: { value: 'sample' } })
|
||||||
fireEvent.keyDown(textBox, { key: 'ArrowDown' })
|
|
||||||
fireEvent.keyDown(textBox, { key: 'Enter' })
|
// Click on first playlist
|
||||||
fireEvent.keyDown(textBox, { key: 'ArrowDown' })
|
const firstPlaylist = screen.getByText('sample playlist 1')
|
||||||
fireEvent.keyDown(textBox, { key: 'Enter' })
|
fireEvent.click(firstPlaylist)
|
||||||
|
|
||||||
|
// Click on second playlist
|
||||||
|
const secondPlaylist = screen.getByText('sample playlist 2')
|
||||||
|
fireEvent.click(secondPlaylist)
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(screen.getByTestId('playlist-add')).not.toBeDisabled()
|
expect(screen.getByTestId('playlist-add')).not.toBeDisabled()
|
||||||
})
|
})
|
||||||
@@ -133,12 +139,11 @@ describe('AddToPlaylistDialog', () => {
|
|||||||
|
|
||||||
createTestUtils(mockDataProvider)
|
createTestUtils(mockDataProvider)
|
||||||
|
|
||||||
|
// Type a new playlist name and press Enter to create it
|
||||||
let textBox = screen.getByRole('textbox')
|
let textBox = screen.getByRole('textbox')
|
||||||
fireEvent.change(textBox, { target: { value: 'sample' } })
|
fireEvent.change(textBox, { target: { value: 'sample' } })
|
||||||
fireEvent.keyDown(textBox, { key: 'ArrowDown' })
|
|
||||||
fireEvent.keyDown(textBox, { key: 'ArrowDown' })
|
|
||||||
fireEvent.keyDown(textBox, { key: 'ArrowDown' })
|
|
||||||
fireEvent.keyDown(textBox, { key: 'Enter' })
|
fireEvent.keyDown(textBox, { key: 'Enter' })
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(screen.getByTestId('playlist-add')).not.toBeDisabled()
|
expect(screen.getByTestId('playlist-add')).not.toBeDisabled()
|
||||||
})
|
})
|
||||||
@@ -171,14 +176,15 @@ describe('AddToPlaylistDialog', () => {
|
|||||||
|
|
||||||
createTestUtils(mockDataProvider)
|
createTestUtils(mockDataProvider)
|
||||||
|
|
||||||
|
// Create first playlist
|
||||||
let textBox = screen.getByRole('textbox')
|
let textBox = screen.getByRole('textbox')
|
||||||
fireEvent.change(textBox, { target: { value: 'sample' } })
|
fireEvent.change(textBox, { target: { value: 'sample' } })
|
||||||
fireEvent.keyDown(textBox, { key: 'ArrowDown' })
|
|
||||||
fireEvent.keyDown(textBox, { key: 'ArrowDown' })
|
|
||||||
fireEvent.keyDown(textBox, { key: 'ArrowDown' })
|
|
||||||
fireEvent.keyDown(textBox, { key: 'Enter' })
|
fireEvent.keyDown(textBox, { key: 'Enter' })
|
||||||
|
|
||||||
|
// Create second playlist
|
||||||
fireEvent.change(textBox, { target: { value: 'new playlist' } })
|
fireEvent.change(textBox, { target: { value: 'new playlist' } })
|
||||||
fireEvent.keyDown(textBox, { key: 'Enter' })
|
fireEvent.keyDown(textBox, { key: 'Enter' })
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(screen.getByTestId('playlist-add')).not.toBeDisabled()
|
expect(screen.getByTestId('playlist-add')).not.toBeDisabled()
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,26 +1,251 @@
|
|||||||
import React from 'react'
|
import React, { useState } from 'react'
|
||||||
import TextField from '@material-ui/core/TextField'
|
import TextField from '@material-ui/core/TextField'
|
||||||
import Checkbox from '@material-ui/core/Checkbox'
|
import Checkbox from '@material-ui/core/Checkbox'
|
||||||
import CheckBoxIcon from '@material-ui/icons/CheckBox'
|
import CheckBoxIcon from '@material-ui/icons/CheckBox'
|
||||||
import CheckBoxOutlineBlankIcon from '@material-ui/icons/CheckBoxOutlineBlank'
|
import CheckBoxOutlineBlankIcon from '@material-ui/icons/CheckBoxOutlineBlank'
|
||||||
import Autocomplete, {
|
import {
|
||||||
createFilterOptions,
|
List,
|
||||||
} from '@material-ui/lab/Autocomplete'
|
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 { useGetList, useTranslate } from 'react-admin'
|
||||||
import PropTypes from 'prop-types'
|
import PropTypes from 'prop-types'
|
||||||
import { isWritable } from '../common'
|
import { isWritable } from '../common'
|
||||||
import { makeStyles } from '@material-ui/core'
|
import { makeStyles } from '@material-ui/core'
|
||||||
|
|
||||||
const filter = createFilterOptions()
|
const useStyles = makeStyles((theme) => ({
|
||||||
|
|
||||||
const useStyles = makeStyles({
|
|
||||||
root: { width: '100%' },
|
root: { width: '100%' },
|
||||||
checkbox: { marginRight: 8 },
|
searchField: {
|
||||||
})
|
marginBottom: theme.spacing(2),
|
||||||
|
width: '100%',
|
||||||
|
},
|
||||||
|
playlistList: {
|
||||||
|
maxHeight: 200,
|
||||||
|
overflow: 'auto',
|
||||||
|
border: `1px solid ${theme.palette.divider}`,
|
||||||
|
borderRadius: theme.shape.borderRadius,
|
||||||
|
backgroundColor: theme.palette.background.paper,
|
||||||
|
},
|
||||||
|
listItem: {
|
||||||
|
paddingTop: 0,
|
||||||
|
paddingBottom: 0,
|
||||||
|
},
|
||||||
|
selectedPlaylistsContainer: {
|
||||||
|
marginTop: theme.spacing(2),
|
||||||
|
},
|
||||||
|
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 fontSize="small" />
|
||||||
|
</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')}
|
||||||
|
>
|
||||||
|
{translate('resources.playlist.actions.removeSymbol')}
|
||||||
|
</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 }) => {
|
export const SelectPlaylistInput = ({ onChange }) => {
|
||||||
const classes = useStyles()
|
const classes = useStyles()
|
||||||
const translate = useTranslate()
|
const [searchText, setSearchText] = useState('')
|
||||||
|
const [selectedPlaylists, setSelectedPlaylists] = useState([])
|
||||||
|
|
||||||
const { ids, data } = useGetList(
|
const { ids, data } = useGetList(
|
||||||
'playlist',
|
'playlist',
|
||||||
{ page: 1, perPage: -1 },
|
{ page: 1, perPage: -1 },
|
||||||
@@ -32,92 +257,131 @@ export const SelectPlaylistInput = ({ onChange }) => {
|
|||||||
ids &&
|
ids &&
|
||||||
ids.map((id) => data[id]).filter((option) => isWritable(option.ownerId))
|
ids.map((id) => data[id]).filter((option) => isWritable(option.ownerId))
|
||||||
|
|
||||||
const handleOnChange = (event, newValue) => {
|
// Filter playlists based on search text
|
||||||
let newState = []
|
const filteredOptions =
|
||||||
if (newValue && newValue.length) {
|
options?.filter((option) =>
|
||||||
newValue.forEach((playlistObject) => {
|
option.name.toLowerCase().includes(searchText.toLowerCase()),
|
||||||
if (playlistObject.inputValue) {
|
) || []
|
||||||
newState.push({
|
|
||||||
name: playlistObject.inputValue,
|
const handlePlaylistToggle = (playlist) => {
|
||||||
})
|
const isSelected = selectedPlaylists.some((p) => p.id === playlist.id)
|
||||||
} else if (typeof playlistObject === 'string') {
|
let newSelection
|
||||||
newState.push({
|
|
||||||
name: playlistObject,
|
if (isSelected) {
|
||||||
})
|
newSelection = selectedPlaylists.filter((p) => p.id !== playlist.id)
|
||||||
} else {
|
} else {
|
||||||
newState.push(playlistObject)
|
newSelection = [...selectedPlaylists, playlist]
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
onChange(newState)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const icon = <CheckBoxOutlineBlankIcon fontSize="small" />
|
setSelectedPlaylists(newSelection)
|
||||||
const checkedIcon = <CheckBoxIcon fontSize="small" />
|
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 (
|
return (
|
||||||
<Autocomplete
|
<div className={classes.root}>
|
||||||
multiple
|
<PlaylistSearchField
|
||||||
disableCloseOnSelect
|
searchText={searchText}
|
||||||
onChange={handleOnChange}
|
onSearchChange={setSearchText}
|
||||||
filterOptions={(options, params) => {
|
onCreateNew={handleCreateNew}
|
||||||
const filtered = filter(options, params)
|
onKeyDown={handleKeyDown}
|
||||||
|
canCreateNew={canCreateNew}
|
||||||
|
/>
|
||||||
|
|
||||||
// Suggest the creation of a new value
|
<PlaylistList
|
||||||
if (params.inputValue !== '') {
|
filteredOptions={filteredOptions}
|
||||||
filtered.push({
|
selectedPlaylists={selectedPlaylists}
|
||||||
inputValue: params.inputValue,
|
onPlaylistToggle={handlePlaylistToggle}
|
||||||
name: translate('resources.playlist.actions.addNewPlaylist', {
|
searchText={searchText}
|
||||||
name: params.inputValue,
|
canCreateNew={canCreateNew}
|
||||||
}),
|
onCreateNew={handleCreateNew}
|
||||||
})
|
/>
|
||||||
}
|
|
||||||
|
|
||||||
return filtered
|
<SelectedPlaylistsDisplay
|
||||||
}}
|
selectedPlaylists={selectedPlaylists}
|
||||||
clearOnBlur
|
onRemoveSelected={handleRemoveSelected}
|
||||||
handleHomeEndKeys
|
|
||||||
openOnFocus
|
|
||||||
selectOnFocus
|
|
||||||
id="select-playlist-input"
|
|
||||||
options={options}
|
|
||||||
getOptionLabel={(option) => {
|
|
||||||
// Value selected with enter, right from the input
|
|
||||||
if (typeof option === 'string') {
|
|
||||||
return option
|
|
||||||
}
|
|
||||||
// Add "xxx" option created dynamically
|
|
||||||
if (option.inputValue) {
|
|
||||||
return option.inputValue
|
|
||||||
}
|
|
||||||
// Regular option
|
|
||||||
return option.name
|
|
||||||
}}
|
|
||||||
renderOption={(option, { selected }) => (
|
|
||||||
<React.Fragment>
|
|
||||||
<Checkbox
|
|
||||||
icon={icon}
|
|
||||||
checkedIcon={checkedIcon}
|
|
||||||
className={classes.checkbox}
|
|
||||||
checked={selected}
|
|
||||||
/>
|
|
||||||
{option.name}
|
|
||||||
</React.Fragment>
|
|
||||||
)}
|
|
||||||
className={classes.root}
|
|
||||||
freeSolo
|
|
||||||
renderInput={(params) => (
|
|
||||||
<TextField
|
|
||||||
autoFocus
|
|
||||||
variant={'outlined'}
|
|
||||||
{...params}
|
|
||||||
label={translate('resources.playlist.fields.name')}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
SelectPlaylistInput.propTypes = {
|
SelectPlaylistInput.propTypes = {
|
||||||
onChange: PropTypes.func.isRequired,
|
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,
|
||||||
|
}
|
||||||
|
|||||||
@@ -11,51 +11,52 @@ import {
|
|||||||
import { SelectPlaylistInput } from './SelectPlaylistInput'
|
import { SelectPlaylistInput } from './SelectPlaylistInput'
|
||||||
import { describe, beforeAll, afterEach, it, expect, vi } from 'vitest'
|
import { describe, beforeAll, afterEach, it, expect, vi } from 'vitest'
|
||||||
|
|
||||||
describe('SelectPlaylistInput', () => {
|
const mockPlaylists = [
|
||||||
beforeAll(() => localStorage.setItem('userId', 'admin'))
|
{ id: 'playlist-1', name: 'Rock Classics', ownerId: 'admin' },
|
||||||
afterEach(cleanup)
|
{ id: 'playlist-2', name: 'Jazz Collection', ownerId: 'admin' },
|
||||||
const onChangeHandler = vi.fn()
|
{ id: 'playlist-3', name: 'Electronic Beats', ownerId: 'admin' },
|
||||||
|
{ id: 'playlist-4', name: 'Chill Vibes', ownerId: 'user2' }, // Not writable by admin
|
||||||
|
]
|
||||||
|
|
||||||
it('should call the handler with the selections', async () => {
|
const mockIndexedData = {
|
||||||
const mockData = [
|
'playlist-1': { id: 'playlist-1', name: 'Rock Classics', ownerId: 'admin' },
|
||||||
{ id: 'sample-id1', name: 'sample playlist 1', ownerId: 'admin' },
|
'playlist-2': { id: 'playlist-2', name: 'Jazz Collection', ownerId: 'admin' },
|
||||||
{ id: 'sample-id2', name: 'sample playlist 2', ownerId: 'admin' },
|
'playlist-3': {
|
||||||
]
|
id: 'playlist-3',
|
||||||
const mockIndexedData = {
|
name: 'Electronic Beats',
|
||||||
'sample-id1': {
|
|
||||||
id: 'sample-id1',
|
|
||||||
name: 'sample playlist 1',
|
|
||||||
ownerId: 'admin',
|
|
||||||
},
|
|
||||||
'sample-id2': {
|
|
||||||
id: 'sample-id2',
|
|
||||||
name: 'sample playlist 2',
|
|
||||||
ownerId: 'admin',
|
ownerId: 'admin',
|
||||||
},
|
},
|
||||||
|
'playlist-4': { id: 'playlist-4', name: 'Chill Vibes', ownerId: 'user2' },
|
||||||
|
}
|
||||||
|
|
||||||
|
const createTestComponent = (
|
||||||
|
mockDataProvider = null,
|
||||||
|
onChangeMock = vi.fn(),
|
||||||
|
playlists = mockPlaylists,
|
||||||
|
indexedData = mockIndexedData,
|
||||||
|
) => {
|
||||||
|
const dataProvider = mockDataProvider || {
|
||||||
|
getList: vi.fn().mockResolvedValue({
|
||||||
|
data: playlists,
|
||||||
|
total: playlists.length,
|
||||||
|
}),
|
||||||
}
|
}
|
||||||
|
|
||||||
const mockDataProvider = {
|
return render(
|
||||||
getList: vi
|
<DataProviderContext.Provider value={dataProvider}>
|
||||||
.fn()
|
|
||||||
.mockResolvedValue({ data: mockData, total: mockData.length }),
|
|
||||||
}
|
|
||||||
|
|
||||||
render(
|
|
||||||
<DataProviderContext.Provider value={mockDataProvider}>
|
|
||||||
<TestContext
|
<TestContext
|
||||||
initialState={{
|
initialState={{
|
||||||
addToPlaylistDialog: { open: true, duplicateSong: false },
|
|
||||||
admin: {
|
admin: {
|
||||||
ui: { optimistic: false },
|
ui: { optimistic: false },
|
||||||
resources: {
|
resources: {
|
||||||
playlist: {
|
playlist: {
|
||||||
data: mockIndexedData,
|
data: indexedData,
|
||||||
list: {
|
list: {
|
||||||
cachedRequests: {
|
cachedRequests: {
|
||||||
'{"pagination":{"page":1,"perPage":-1},"sort":{"field":"name","order":"ASC"},"filter":{"smart":false}}':
|
'{"pagination":{"page":1,"perPage":-1},"sort":{"field":"name","order":"ASC"},"filter":{"smart":false}}':
|
||||||
{
|
{
|
||||||
ids: ['sample-id1', 'sample-id2'],
|
ids: Object.keys(indexedData),
|
||||||
total: 2,
|
total: Object.keys(indexedData).length,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -64,62 +65,429 @@ describe('SelectPlaylistInput', () => {
|
|||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<SelectPlaylistInput onChange={onChangeHandler} />
|
<SelectPlaylistInput onChange={onChangeMock} />
|
||||||
</TestContext>
|
</TestContext>
|
||||||
</DataProviderContext.Provider>,
|
</DataProviderContext.Provider>,
|
||||||
)
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('SelectPlaylistInput', () => {
|
||||||
|
beforeAll(() => localStorage.setItem('userId', 'admin'))
|
||||||
|
afterEach(cleanup)
|
||||||
|
|
||||||
|
describe('Basic Functionality', () => {
|
||||||
|
it('should render search field and playlist list', async () => {
|
||||||
|
const onChangeMock = vi.fn()
|
||||||
|
createTestComponent(null, onChangeMock)
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(mockDataProvider.getList).toHaveBeenCalledWith('playlist', {
|
expect(screen.getByRole('textbox')).toBeInTheDocument()
|
||||||
filter: { smart: false },
|
expect(screen.getByText('Rock Classics')).toBeInTheDocument()
|
||||||
pagination: { page: 1, perPage: -1 },
|
expect(screen.getByText('Jazz Collection')).toBeInTheDocument()
|
||||||
sort: { field: 'name', order: 'ASC' },
|
expect(screen.getByText('Electronic Beats')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
// Should not show playlists not owned by admin (not writable)
|
||||||
|
expect(screen.queryByText('Chill Vibes')).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should filter playlists based on search input', async () => {
|
||||||
|
const onChangeMock = vi.fn()
|
||||||
|
createTestComponent(null, onChangeMock)
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('Rock Classics')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
const searchInput = screen.getByRole('textbox')
|
||||||
|
fireEvent.change(searchInput, { target: { value: 'rock' } })
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('Rock Classics')).toBeInTheDocument()
|
||||||
|
expect(screen.queryByText('Jazz Collection')).not.toBeInTheDocument()
|
||||||
|
expect(screen.queryByText('Electronic Beats')).not.toBeInTheDocument()
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
let textBox = screen.getByRole('textbox')
|
it('should handle case-insensitive search', async () => {
|
||||||
fireEvent.change(textBox, { target: { value: 'sample' } })
|
const onChangeMock = vi.fn()
|
||||||
fireEvent.keyDown(textBox, { key: 'ArrowDown' })
|
createTestComponent(null, onChangeMock)
|
||||||
fireEvent.keyDown(textBox, { key: 'Enter' })
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(onChangeHandler).toHaveBeenCalledWith([
|
expect(screen.getByText('Jazz Collection')).toBeInTheDocument()
|
||||||
{ id: 'sample-id1', name: 'sample playlist 1', ownerId: 'admin' },
|
})
|
||||||
|
|
||||||
|
const searchInput = screen.getByRole('textbox')
|
||||||
|
fireEvent.change(searchInput, { target: { value: 'JAZZ' } })
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('Jazz Collection')).toBeInTheDocument()
|
||||||
|
expect(screen.queryByText('Rock Classics')).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Playlist Selection', () => {
|
||||||
|
it('should select and deselect playlists by clicking', async () => {
|
||||||
|
const onChangeMock = vi.fn()
|
||||||
|
createTestComponent(null, onChangeMock)
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('Rock Classics')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
// Select first playlist
|
||||||
|
const rockPlaylist = screen.getByText('Rock Classics')
|
||||||
|
fireEvent.click(rockPlaylist)
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(onChangeMock).toHaveBeenCalledWith([
|
||||||
|
{ id: 'playlist-1', name: 'Rock Classics', ownerId: 'admin' },
|
||||||
])
|
])
|
||||||
})
|
})
|
||||||
|
|
||||||
fireEvent.keyDown(textBox, { key: 'ArrowDown' })
|
// Select second playlist
|
||||||
fireEvent.keyDown(textBox, { key: 'Enter' })
|
const jazzPlaylist = screen.getByText('Jazz Collection')
|
||||||
|
fireEvent.click(jazzPlaylist)
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(onChangeHandler).toHaveBeenCalledWith([
|
expect(onChangeMock).toHaveBeenCalledWith([
|
||||||
{ id: 'sample-id1', name: 'sample playlist 1', ownerId: 'admin' },
|
{ id: 'playlist-1', name: 'Rock Classics', ownerId: 'admin' },
|
||||||
{ id: 'sample-id2', name: 'sample playlist 2', ownerId: 'admin' },
|
{ id: 'playlist-2', name: 'Jazz Collection', ownerId: 'admin' },
|
||||||
])
|
])
|
||||||
})
|
})
|
||||||
|
|
||||||
fireEvent.change(textBox, {
|
// Deselect first playlist
|
||||||
target: { value: 'new playlist' },
|
fireEvent.click(rockPlaylist)
|
||||||
})
|
|
||||||
fireEvent.keyDown(textBox, { key: 'ArrowDown' })
|
|
||||||
fireEvent.keyDown(textBox, { key: 'Enter' })
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(onChangeHandler).toHaveBeenCalledWith([
|
expect(onChangeMock).toHaveBeenCalledWith([
|
||||||
{ id: 'sample-id1', name: 'sample playlist 1', ownerId: 'admin' },
|
{ id: 'playlist-2', name: 'Jazz Collection', ownerId: 'admin' },
|
||||||
{ id: 'sample-id2', name: 'sample playlist 2', ownerId: 'admin' },
|
])
|
||||||
{ name: 'new playlist' },
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should show selected playlists as chips', async () => {
|
||||||
|
const onChangeMock = vi.fn()
|
||||||
|
createTestComponent(null, onChangeMock)
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('Rock Classics')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
// Select a playlist
|
||||||
|
const rockPlaylist = screen.getByText('Rock Classics')
|
||||||
|
fireEvent.click(rockPlaylist)
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
// Should show the selected playlist as a chip
|
||||||
|
const chips = screen.getAllByText('Rock Classics')
|
||||||
|
expect(chips.length).toBeGreaterThan(1) // One in list, one in chip
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should remove selected playlists via chip remove button', async () => {
|
||||||
|
const onChangeMock = vi.fn()
|
||||||
|
createTestComponent(null, onChangeMock)
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('Rock Classics')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
// Select a playlist
|
||||||
|
const rockPlaylist = screen.getByText('Rock Classics')
|
||||||
|
fireEvent.click(rockPlaylist)
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
// Should show selected playlist as chip
|
||||||
|
const chips = screen.getAllByText('Rock Classics')
|
||||||
|
expect(chips.length).toBeGreaterThan(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Find and click the remove button (translation key)
|
||||||
|
const removeButton = screen.getByText(
|
||||||
|
'resources.playlist.actions.removeSymbol',
|
||||||
|
)
|
||||||
|
fireEvent.click(removeButton)
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(onChangeMock).toHaveBeenCalledWith([])
|
||||||
|
// Should only have one instance (in the list) after removal
|
||||||
|
const remainingChips = screen.getAllByText('Rock Classics')
|
||||||
|
expect(remainingChips.length).toBe(1)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Create New Playlist', () => {
|
||||||
|
it('should create new playlist by pressing Enter', async () => {
|
||||||
|
const onChangeMock = vi.fn()
|
||||||
|
createTestComponent(null, onChangeMock)
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByRole('textbox')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
const searchInput = screen.getByRole('textbox')
|
||||||
|
fireEvent.change(searchInput, { target: { value: 'My New Playlist' } })
|
||||||
|
fireEvent.keyDown(searchInput, { key: 'Enter' })
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(onChangeMock).toHaveBeenCalledWith([{ name: 'My New Playlist' }])
|
||||||
|
})
|
||||||
|
|
||||||
|
// Input should be cleared after creating
|
||||||
|
expect(searchInput.value).toBe('')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should create new playlist by clicking add button', async () => {
|
||||||
|
const onChangeMock = vi.fn()
|
||||||
|
createTestComponent(null, onChangeMock)
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByRole('textbox')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
const searchInput = screen.getByRole('textbox')
|
||||||
|
fireEvent.change(searchInput, { target: { value: 'Another Playlist' } })
|
||||||
|
|
||||||
|
// Find the add button by the translation key title
|
||||||
|
const addButton = screen.getByTitle(
|
||||||
|
'resources.playlist.actions.addNewPlaylist',
|
||||||
|
)
|
||||||
|
fireEvent.click(addButton)
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(onChangeMock).toHaveBeenCalledWith([
|
||||||
|
{ name: 'Another Playlist' },
|
||||||
|
])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should not show create option for existing playlist names', async () => {
|
||||||
|
const onChangeMock = vi.fn()
|
||||||
|
createTestComponent(null, onChangeMock)
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByRole('textbox')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
const searchInput = screen.getByRole('textbox')
|
||||||
|
fireEvent.change(searchInput, { target: { value: 'Rock Classics' } })
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(
|
||||||
|
screen.queryByText('resources.playlist.actions.addNewPlaylist'),
|
||||||
|
).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should not create playlist with empty name', async () => {
|
||||||
|
const onChangeMock = vi.fn()
|
||||||
|
createTestComponent(null, onChangeMock)
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByRole('textbox')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
const searchInput = screen.getByRole('textbox')
|
||||||
|
fireEvent.change(searchInput, { target: { value: ' ' } }) // Only spaces
|
||||||
|
fireEvent.keyDown(searchInput, { key: 'Enter' })
|
||||||
|
|
||||||
|
// Should not call onChange
|
||||||
|
expect(onChangeMock).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should show create options in appropriate contexts', async () => {
|
||||||
|
const onChangeMock = vi.fn()
|
||||||
|
createTestComponent(null, onChangeMock)
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByRole('textbox')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
const searchInput = screen.getByRole('textbox')
|
||||||
|
|
||||||
|
// When typing a new name, should show create options
|
||||||
|
fireEvent.change(searchInput, { target: { value: 'My New Playlist' } })
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
// Should show the add button in the search field
|
||||||
|
expect(
|
||||||
|
screen.getByTitle('resources.playlist.actions.addNewPlaylist'),
|
||||||
|
).toBeInTheDocument()
|
||||||
|
// Should also show hint in empty message when no matches
|
||||||
|
expect(
|
||||||
|
screen.getByText('resources.playlist.actions.pressEnterToCreate'),
|
||||||
|
).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Mixed Operations', () => {
|
||||||
|
it('should handle selecting existing playlists and creating new ones', async () => {
|
||||||
|
const onChangeMock = vi.fn()
|
||||||
|
createTestComponent(null, onChangeMock)
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('Rock Classics')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
// Select existing playlist
|
||||||
|
const rockPlaylist = screen.getByText('Rock Classics')
|
||||||
|
fireEvent.click(rockPlaylist)
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(onChangeMock).toHaveBeenCalledWith([
|
||||||
|
{ id: 'playlist-1', name: 'Rock Classics', ownerId: 'admin' },
|
||||||
])
|
])
|
||||||
})
|
})
|
||||||
|
|
||||||
fireEvent.change(textBox, {
|
// Create new playlist
|
||||||
target: { value: 'another new playlist' },
|
const searchInput = screen.getByRole('textbox')
|
||||||
})
|
fireEvent.change(searchInput, { target: { value: 'New Mix' } })
|
||||||
fireEvent.keyDown(textBox, { key: 'Enter' })
|
fireEvent.keyDown(searchInput, { key: 'Enter' })
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(onChangeHandler).toHaveBeenCalledWith([
|
expect(onChangeMock).toHaveBeenCalledWith([
|
||||||
{ id: 'sample-id1', name: 'sample playlist 1', ownerId: 'admin' },
|
{ id: 'playlist-1', name: 'Rock Classics', ownerId: 'admin' },
|
||||||
{ id: 'sample-id2', name: 'sample playlist 2', ownerId: 'admin' },
|
{ name: 'New Mix' },
|
||||||
{ name: 'new playlist' },
|
|
||||||
{ name: 'another new playlist' },
|
|
||||||
])
|
])
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('should maintain selections when searching', async () => {
|
||||||
|
const onChangeMock = vi.fn()
|
||||||
|
createTestComponent(null, onChangeMock)
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('Rock Classics')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
// Select a playlist
|
||||||
|
const rockPlaylist = screen.getByText('Rock Classics')
|
||||||
|
fireEvent.click(rockPlaylist)
|
||||||
|
|
||||||
|
// Filter the list
|
||||||
|
const searchInput = screen.getByRole('textbox')
|
||||||
|
fireEvent.change(searchInput, { target: { value: 'jazz' } })
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
// Should still show selected playlists section
|
||||||
|
// Rock Classics should still be visible as a selected chip even though filtered out
|
||||||
|
expect(screen.getByText('Rock Classics')).toBeInTheDocument() // In selected chips
|
||||||
|
expect(screen.getByText('Jazz Collection')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Empty States', () => {
|
||||||
|
it('should show empty message when no playlists exist', async () => {
|
||||||
|
const onChangeMock = vi.fn()
|
||||||
|
createTestComponent(null, onChangeMock, [], {})
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(
|
||||||
|
screen.getByText('resources.playlist.message.noPlaylists'),
|
||||||
|
).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should show "no results" message when search returns no matches', async () => {
|
||||||
|
const onChangeMock = vi.fn()
|
||||||
|
createTestComponent(null, onChangeMock)
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByRole('textbox')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
const searchInput = screen.getByRole('textbox')
|
||||||
|
fireEvent.change(searchInput, {
|
||||||
|
target: { value: 'NonExistentPlaylist' },
|
||||||
|
})
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(
|
||||||
|
screen.getByText('resources.playlist.message.noPlaylistsFound'),
|
||||||
|
).toBeInTheDocument()
|
||||||
|
expect(
|
||||||
|
screen.getByText('resources.playlist.actions.pressEnterToCreate'),
|
||||||
|
).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Keyboard Navigation', () => {
|
||||||
|
it('should not create playlist on Enter if input is empty', async () => {
|
||||||
|
const onChangeMock = vi.fn()
|
||||||
|
createTestComponent(null, onChangeMock)
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByRole('textbox')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
const searchInput = screen.getByRole('textbox')
|
||||||
|
fireEvent.keyDown(searchInput, { key: 'Enter' })
|
||||||
|
|
||||||
|
expect(onChangeMock).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should handle other keys without side effects', async () => {
|
||||||
|
const onChangeMock = vi.fn()
|
||||||
|
createTestComponent(null, onChangeMock)
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByRole('textbox')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
const searchInput = screen.getByRole('textbox')
|
||||||
|
fireEvent.change(searchInput, { target: { value: 'test' } })
|
||||||
|
fireEvent.keyDown(searchInput, { key: 'ArrowDown' })
|
||||||
|
fireEvent.keyDown(searchInput, { key: 'Tab' })
|
||||||
|
fireEvent.keyDown(searchInput, { key: 'Escape' })
|
||||||
|
|
||||||
|
// Should not create playlist or trigger onChange
|
||||||
|
expect(onChangeMock).not.toHaveBeenCalled()
|
||||||
|
expect(searchInput.value).toBe('test')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Integration Scenarios', () => {
|
||||||
|
it('should handle complex workflow: search, select, create, remove', async () => {
|
||||||
|
const onChangeMock = vi.fn()
|
||||||
|
createTestComponent(null, onChangeMock)
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('Rock Classics')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
// Search and select existing playlist
|
||||||
|
const searchInput = screen.getByRole('textbox')
|
||||||
|
fireEvent.change(searchInput, { target: { value: 'rock' } })
|
||||||
|
|
||||||
|
const rockPlaylist = screen.getByText('Rock Classics')
|
||||||
|
fireEvent.click(rockPlaylist)
|
||||||
|
|
||||||
|
// Clear search and create new playlist
|
||||||
|
fireEvent.change(searchInput, { target: { value: 'My Custom Mix' } })
|
||||||
|
fireEvent.keyDown(searchInput, { key: 'Enter' })
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(onChangeMock).toHaveBeenCalledWith([
|
||||||
|
{ id: 'playlist-1', name: 'Rock Classics', ownerId: 'admin' },
|
||||||
|
{ name: 'My Custom Mix' },
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
// Remove the first selected playlist via chip
|
||||||
|
const removeButtons = screen.getAllByText(
|
||||||
|
'resources.playlist.actions.removeSymbol',
|
||||||
|
)
|
||||||
|
fireEvent.click(removeButtons[0])
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(onChangeMock).toHaveBeenCalledWith([{ name: 'My Custom Mix' }])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
+8
-2
@@ -198,11 +198,17 @@
|
|||||||
"export": "Export",
|
"export": "Export",
|
||||||
"saveQueue": "Save Queue to Playlist",
|
"saveQueue": "Save Queue to Playlist",
|
||||||
"makePublic": "Make Public",
|
"makePublic": "Make Public",
|
||||||
"makePrivate": "Make Private"
|
"makePrivate": "Make Private",
|
||||||
|
"searchOrCreate": "Search playlists or type to create new...",
|
||||||
|
"pressEnterToCreate": "Press Enter to create new playlist",
|
||||||
|
"removeFromSelection": "Remove from selection",
|
||||||
|
"removeSymbol": "×"
|
||||||
},
|
},
|
||||||
"message": {
|
"message": {
|
||||||
"duplicate_song": "Add duplicated songs",
|
"duplicate_song": "Add duplicated songs",
|
||||||
"song_exist": "There are duplicates being added to the playlist. Would you like to add the duplicates or skip them?"
|
"song_exist": "There are duplicates being added to the playlist. Would you like to add the duplicates or skip them?",
|
||||||
|
"noPlaylistsFound": "No playlists found",
|
||||||
|
"noPlaylists": "No playlists available"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"radio": {
|
"radio": {
|
||||||
|
|||||||
Reference in New Issue
Block a user