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:
Deluan Quintão
2025-05-30 23:15:02 -04:00
committed by GitHub
parent 7bb1fcdd4b
commit 8e32eeae93
5 changed files with 840 additions and 190 deletions
+8 -2
View File
@@ -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": {
+16 -10
View File
@@ -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()
}) })
+349 -85
View File
@@ -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,
}
+432 -64
View File
@@ -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
View File
@@ -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": {