feat(ui) add Save Queue to Playlist (#4110)
* ui: add save queue to playlist * fix(ui): improve toolbar layout Signed-off-by: Deluan <deluan@navidrome.org> * fix(ui): add loading state to save queue dialog Signed-off-by: Deluan <deluan@navidrome.org> * fix(ui): refresh playlist after saving queue Signed-off-by: Deluan <deluan@navidrome.org> * fix lint Signed-off-by: Deluan <deluan@navidrome.org> * remove duplication in PlayerToolbar and add tests Signed-off-by: Deluan <deluan@navidrome.org> * fix(i18n): update save queue text for clarity in English and Portuguese Signed-off-by: Deluan <deluan@navidrome.org> --------- Signed-off-by: Deluan <deluan@navidrome.org>
This commit is contained in:
@@ -191,6 +191,7 @@
|
|||||||
"selectPlaylist": "Selecione a playlist:",
|
"selectPlaylist": "Selecione a playlist:",
|
||||||
"addNewPlaylist": "Criar \"%{name}\"",
|
"addNewPlaylist": "Criar \"%{name}\"",
|
||||||
"export": "Exportar",
|
"export": "Exportar",
|
||||||
|
"saveQueue": "Salvar fila em nova Playlist",
|
||||||
"makePublic": "Pública",
|
"makePublic": "Pública",
|
||||||
"makePrivate": "Pessoal"
|
"makePrivate": "Pessoal"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import {
|
|||||||
addToPlaylistDialogReducer,
|
addToPlaylistDialogReducer,
|
||||||
expandInfoDialogReducer,
|
expandInfoDialogReducer,
|
||||||
listenBrainzTokenDialogReducer,
|
listenBrainzTokenDialogReducer,
|
||||||
|
saveQueueDialogReducer,
|
||||||
playerReducer,
|
playerReducer,
|
||||||
albumViewReducer,
|
albumViewReducer,
|
||||||
activityReducer,
|
activityReducer,
|
||||||
@@ -62,6 +63,7 @@ const adminStore = createAdminStore({
|
|||||||
downloadMenuDialog: downloadMenuDialogReducer,
|
downloadMenuDialog: downloadMenuDialogReducer,
|
||||||
expandInfoDialog: expandInfoDialogReducer,
|
expandInfoDialog: expandInfoDialogReducer,
|
||||||
listenBrainzTokenDialog: listenBrainzTokenDialogReducer,
|
listenBrainzTokenDialog: listenBrainzTokenDialogReducer,
|
||||||
|
saveQueueDialog: saveQueueDialogReducer,
|
||||||
shareDialog: shareDialogReducer,
|
shareDialog: shareDialogReducer,
|
||||||
activity: activityReducer,
|
activity: activityReducer,
|
||||||
settings: settingsReducer,
|
settings: settingsReducer,
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ export const EXTENDED_INFO_OPEN = 'EXTENDED_INFO_OPEN'
|
|||||||
export const EXTENDED_INFO_CLOSE = 'EXTENDED_INFO_CLOSE'
|
export const EXTENDED_INFO_CLOSE = 'EXTENDED_INFO_CLOSE'
|
||||||
export const LISTENBRAINZ_TOKEN_OPEN = 'LISTENBRAINZ_TOKEN_OPEN'
|
export const LISTENBRAINZ_TOKEN_OPEN = 'LISTENBRAINZ_TOKEN_OPEN'
|
||||||
export const LISTENBRAINZ_TOKEN_CLOSE = 'LISTENBRAINZ_TOKEN_CLOSE'
|
export const LISTENBRAINZ_TOKEN_CLOSE = 'LISTENBRAINZ_TOKEN_CLOSE'
|
||||||
|
export const SAVE_QUEUE_OPEN = 'SAVE_QUEUE_OPEN'
|
||||||
|
export const SAVE_QUEUE_CLOSE = 'SAVE_QUEUE_CLOSE'
|
||||||
export const DOWNLOAD_MENU_ALBUM = 'album'
|
export const DOWNLOAD_MENU_ALBUM = 'album'
|
||||||
export const DOWNLOAD_MENU_ARTIST = 'artist'
|
export const DOWNLOAD_MENU_ARTIST = 'artist'
|
||||||
export const DOWNLOAD_MENU_PLAY = 'playlist'
|
export const DOWNLOAD_MENU_PLAY = 'playlist'
|
||||||
@@ -76,3 +78,11 @@ export const openListenBrainzTokenDialog = () => ({
|
|||||||
export const closeListenBrainzTokenDialog = () => ({
|
export const closeListenBrainzTokenDialog = () => ({
|
||||||
type: LISTENBRAINZ_TOKEN_CLOSE,
|
type: LISTENBRAINZ_TOKEN_CLOSE,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
export const openSaveQueueDialog = () => ({
|
||||||
|
type: SAVE_QUEUE_OPEN,
|
||||||
|
})
|
||||||
|
|
||||||
|
export const closeSaveQueueDialog = () => ({
|
||||||
|
type: SAVE_QUEUE_CLOSE,
|
||||||
|
})
|
||||||
|
|||||||
@@ -1,32 +1,120 @@
|
|||||||
import React, { useCallback } from 'react'
|
import React, { useCallback } from 'react'
|
||||||
|
import { useDispatch } from 'react-redux'
|
||||||
import { useGetOne } from 'react-admin'
|
import { useGetOne } from 'react-admin'
|
||||||
import { GlobalHotKeys } from 'react-hotkeys'
|
import { GlobalHotKeys } from 'react-hotkeys'
|
||||||
|
import IconButton from '@material-ui/core/IconButton'
|
||||||
|
import { useMediaQuery } from '@material-ui/core'
|
||||||
|
import { RiSaveLine } from 'react-icons/ri'
|
||||||
import { LoveButton, useToggleLove } from '../common'
|
import { LoveButton, useToggleLove } from '../common'
|
||||||
|
import { openSaveQueueDialog } from '../actions'
|
||||||
import { keyMap } from '../hotkeys'
|
import { keyMap } from '../hotkeys'
|
||||||
|
import { makeStyles } from '@material-ui/core/styles'
|
||||||
|
|
||||||
const Placeholder = () => <LoveButton disabled={true} resource={'song'} />
|
const useStyles = makeStyles((theme) => ({
|
||||||
|
toolbar: {
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
flexGrow: 1,
|
||||||
|
justifyContent: 'flex-end',
|
||||||
|
gap: '0.5rem',
|
||||||
|
listStyle: 'none',
|
||||||
|
padding: 0,
|
||||||
|
margin: 0,
|
||||||
|
},
|
||||||
|
mobileListItem: {
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
listStyle: 'none',
|
||||||
|
padding: theme.spacing(0.5),
|
||||||
|
margin: 0,
|
||||||
|
height: 24,
|
||||||
|
},
|
||||||
|
button: {
|
||||||
|
width: '2.5rem',
|
||||||
|
height: '2.5rem',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
padding: 0,
|
||||||
|
},
|
||||||
|
mobileButton: {
|
||||||
|
width: 24,
|
||||||
|
height: 24,
|
||||||
|
padding: 0,
|
||||||
|
margin: 0,
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
fontSize: '18px',
|
||||||
|
},
|
||||||
|
mobileIcon: {
|
||||||
|
fontSize: '18px',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
|
||||||
const Toolbar = ({ id }) => {
|
const PlayerToolbar = ({ id, isRadio }) => {
|
||||||
|
const dispatch = useDispatch()
|
||||||
const { data, loading } = useGetOne('song', id)
|
const { data, loading } = useGetOne('song', id)
|
||||||
const [toggleLove, toggling] = useToggleLove('song', data)
|
const [toggleLove, toggling] = useToggleLove('song', data)
|
||||||
|
const isDesktop = useMediaQuery('(min-width:810px)')
|
||||||
|
const classes = useStyles()
|
||||||
|
|
||||||
const handlers = {
|
const handlers = {
|
||||||
TOGGLE_LOVE: useCallback(() => toggleLove(), [toggleLove]),
|
TOGGLE_LOVE: useCallback(() => toggleLove(), [toggleLove]),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleSaveQueue = useCallback(
|
||||||
|
(e) => {
|
||||||
|
dispatch(openSaveQueueDialog())
|
||||||
|
e.stopPropagation()
|
||||||
|
},
|
||||||
|
[dispatch],
|
||||||
|
)
|
||||||
|
|
||||||
|
const buttonClass = isDesktop ? classes.button : classes.mobileButton
|
||||||
|
const listItemClass = isDesktop ? classes.toolbar : classes.mobileListItem
|
||||||
|
|
||||||
|
const saveQueueButton = (
|
||||||
|
<IconButton
|
||||||
|
size={isDesktop ? 'small' : undefined}
|
||||||
|
onClick={handleSaveQueue}
|
||||||
|
disabled={isRadio}
|
||||||
|
data-testid="save-queue-button"
|
||||||
|
className={buttonClass}
|
||||||
|
>
|
||||||
|
<RiSaveLine className={!isDesktop ? classes.mobileIcon : undefined} />
|
||||||
|
</IconButton>
|
||||||
|
)
|
||||||
|
|
||||||
|
const loveButton = (
|
||||||
|
<LoveButton
|
||||||
|
record={data}
|
||||||
|
resource={'song'}
|
||||||
|
size={isDesktop ? undefined : 'inherit'}
|
||||||
|
disabled={loading || toggling || !id || isRadio}
|
||||||
|
className={buttonClass}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<GlobalHotKeys keyMap={keyMap} handlers={handlers} allowChanges />
|
<GlobalHotKeys keyMap={keyMap} handlers={handlers} allowChanges />
|
||||||
<LoveButton
|
{isDesktop ? (
|
||||||
record={data}
|
<li className={`${listItemClass} item`}>
|
||||||
resource={'song'}
|
{saveQueueButton}
|
||||||
disabled={loading || toggling}
|
{loveButton}
|
||||||
/>
|
</li>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<li className={`${listItemClass} item`}>{saveQueueButton}</li>
|
||||||
|
<li className={`${listItemClass} item`}>{loveButton}</li>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const PlayerToolbar = ({ id, isRadio }) =>
|
|
||||||
id && !isRadio ? <Toolbar id={id} /> : <Placeholder />
|
|
||||||
|
|
||||||
export default PlayerToolbar
|
export default PlayerToolbar
|
||||||
|
|||||||
@@ -0,0 +1,166 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import { render, screen, fireEvent, cleanup } from '@testing-library/react'
|
||||||
|
import { useMediaQuery } from '@material-ui/core'
|
||||||
|
import { useGetOne } from 'react-admin'
|
||||||
|
import { useDispatch } from 'react-redux'
|
||||||
|
import { useToggleLove } from '../common'
|
||||||
|
import { openSaveQueueDialog } from '../actions'
|
||||||
|
import PlayerToolbar from './PlayerToolbar'
|
||||||
|
|
||||||
|
// Mock dependencies
|
||||||
|
vi.mock('@material-ui/core', async () => {
|
||||||
|
const actual = await import('@material-ui/core')
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
useMediaQuery: vi.fn(),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
vi.mock('react-admin', () => ({
|
||||||
|
useGetOne: vi.fn(),
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('react-redux', () => ({
|
||||||
|
useDispatch: vi.fn(),
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('../common', () => ({
|
||||||
|
LoveButton: ({ className, disabled }) => (
|
||||||
|
<button data-testid="love-button" className={className} disabled={disabled}>
|
||||||
|
Love
|
||||||
|
</button>
|
||||||
|
),
|
||||||
|
useToggleLove: vi.fn(),
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('../actions', () => ({
|
||||||
|
openSaveQueueDialog: vi.fn(),
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('react-hotkeys', () => ({
|
||||||
|
GlobalHotKeys: () => <div data-testid="global-hotkeys" />,
|
||||||
|
}))
|
||||||
|
|
||||||
|
describe('<PlayerToolbar />', () => {
|
||||||
|
const mockToggleLove = vi.fn()
|
||||||
|
const mockDispatch = vi.fn()
|
||||||
|
const mockSongData = { id: 'song-1', name: 'Test Song', starred: false }
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks()
|
||||||
|
useGetOne.mockReturnValue({ data: mockSongData, loading: false })
|
||||||
|
useToggleLove.mockReturnValue([mockToggleLove, false])
|
||||||
|
useDispatch.mockReturnValue(mockDispatch)
|
||||||
|
openSaveQueueDialog.mockReturnValue({ type: 'OPEN_SAVE_QUEUE_DIALOG' })
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(cleanup)
|
||||||
|
|
||||||
|
describe('Desktop layout', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
useMediaQuery.mockReturnValue(true) // isDesktop = true
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders desktop toolbar with both buttons', () => {
|
||||||
|
render(<PlayerToolbar id="song-1" />)
|
||||||
|
|
||||||
|
// Both buttons should be in a single list item
|
||||||
|
const listItems = screen.getAllByRole('listitem')
|
||||||
|
expect(listItems).toHaveLength(1)
|
||||||
|
|
||||||
|
// Verify both buttons are rendered
|
||||||
|
expect(screen.getByTestId('save-queue-button')).toBeInTheDocument()
|
||||||
|
expect(screen.getByTestId('love-button')).toBeInTheDocument()
|
||||||
|
|
||||||
|
// Verify desktop classes are applied
|
||||||
|
expect(listItems[0].className).toContain('toolbar')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('disables save queue button when isRadio is true', () => {
|
||||||
|
render(<PlayerToolbar id="song-1" isRadio={true} />)
|
||||||
|
|
||||||
|
const saveQueueButton = screen.getByTestId('save-queue-button')
|
||||||
|
expect(saveQueueButton).toBeDisabled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('disables love button when conditions are met', () => {
|
||||||
|
useGetOne.mockReturnValue({ data: mockSongData, loading: true })
|
||||||
|
|
||||||
|
render(<PlayerToolbar id="song-1" />)
|
||||||
|
|
||||||
|
const loveButton = screen.getByTestId('love-button')
|
||||||
|
expect(loveButton).toBeDisabled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('opens save queue dialog when save button is clicked', () => {
|
||||||
|
render(<PlayerToolbar id="song-1" />)
|
||||||
|
|
||||||
|
const saveQueueButton = screen.getByTestId('save-queue-button')
|
||||||
|
fireEvent.click(saveQueueButton)
|
||||||
|
|
||||||
|
expect(mockDispatch).toHaveBeenCalledWith({
|
||||||
|
type: 'OPEN_SAVE_QUEUE_DIALOG',
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Mobile layout', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
useMediaQuery.mockReturnValue(false) // isDesktop = false
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders mobile toolbar with buttons in separate list items', () => {
|
||||||
|
render(<PlayerToolbar id="song-1" />)
|
||||||
|
|
||||||
|
// Each button should be in its own list item
|
||||||
|
const listItems = screen.getAllByRole('listitem')
|
||||||
|
expect(listItems).toHaveLength(2)
|
||||||
|
|
||||||
|
// Verify both buttons are rendered
|
||||||
|
expect(screen.getByTestId('save-queue-button')).toBeInTheDocument()
|
||||||
|
expect(screen.getByTestId('love-button')).toBeInTheDocument()
|
||||||
|
|
||||||
|
// Verify mobile classes are applied
|
||||||
|
expect(listItems[0].className).toContain('mobileListItem')
|
||||||
|
expect(listItems[1].className).toContain('mobileListItem')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('disables save queue button when isRadio is true', () => {
|
||||||
|
render(<PlayerToolbar id="song-1" isRadio={true} />)
|
||||||
|
|
||||||
|
const saveQueueButton = screen.getByTestId('save-queue-button')
|
||||||
|
expect(saveQueueButton).toBeDisabled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('disables love button when conditions are met', () => {
|
||||||
|
useGetOne.mockReturnValue({ data: mockSongData, loading: true })
|
||||||
|
|
||||||
|
render(<PlayerToolbar id="song-1" />)
|
||||||
|
|
||||||
|
const loveButton = screen.getByTestId('love-button')
|
||||||
|
expect(loveButton).toBeDisabled()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Common behavior', () => {
|
||||||
|
it('renders global hotkeys in both layouts', () => {
|
||||||
|
// Test desktop layout
|
||||||
|
useMediaQuery.mockReturnValue(true)
|
||||||
|
render(<PlayerToolbar id="song-1" />)
|
||||||
|
expect(screen.getByTestId('global-hotkeys')).toBeInTheDocument()
|
||||||
|
|
||||||
|
// Cleanup and test mobile layout
|
||||||
|
cleanup()
|
||||||
|
useMediaQuery.mockReturnValue(false)
|
||||||
|
render(<PlayerToolbar id="song-1" />)
|
||||||
|
expect(screen.getByTestId('global-hotkeys')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('disables buttons when id is not provided', () => {
|
||||||
|
render(<PlayerToolbar />)
|
||||||
|
|
||||||
|
const loveButton = screen.getByTestId('love-button')
|
||||||
|
expect(loveButton).toBeDisabled()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -25,7 +25,10 @@ export const CollapsibleComment = ({ record }) => {
|
|||||||
const classes = useStyles()
|
const classes = useStyles()
|
||||||
const [expanded, setExpanded] = useState(false)
|
const [expanded, setExpanded] = useState(false)
|
||||||
|
|
||||||
const lines = record.comment.split('\n')
|
const lines = useMemo(
|
||||||
|
() => record.comment?.split('\n') || [],
|
||||||
|
[record.comment],
|
||||||
|
)
|
||||||
const formatted = useMemo(() => {
|
const formatted = useMemo(() => {
|
||||||
return lines.map((line, idx) => (
|
return lines.map((line, idx) => (
|
||||||
<span key={record.id + '-comment-' + idx}>
|
<span key={record.id + '-comment-' + idx}>
|
||||||
|
|||||||
@@ -2,10 +2,12 @@ import { AddToPlaylistDialog } from './AddToPlaylistDialog'
|
|||||||
import DownloadMenuDialog from './DownloadMenuDialog'
|
import DownloadMenuDialog from './DownloadMenuDialog'
|
||||||
import { HelpDialog } from './HelpDialog'
|
import { HelpDialog } from './HelpDialog'
|
||||||
import { ShareDialog } from './ShareDialog'
|
import { ShareDialog } from './ShareDialog'
|
||||||
|
import { SaveQueueDialog } from './SaveQueueDialog'
|
||||||
|
|
||||||
export const Dialogs = (props) => (
|
export const Dialogs = (props) => (
|
||||||
<>
|
<>
|
||||||
<AddToPlaylistDialog />
|
<AddToPlaylistDialog />
|
||||||
|
<SaveQueueDialog />
|
||||||
<DownloadMenuDialog />
|
<DownloadMenuDialog />
|
||||||
<HelpDialog />
|
<HelpDialog />
|
||||||
<ShareDialog />
|
<ShareDialog />
|
||||||
|
|||||||
@@ -0,0 +1,117 @@
|
|||||||
|
import React, { useState, useCallback } from 'react'
|
||||||
|
import { useDispatch, useSelector } from 'react-redux'
|
||||||
|
import {
|
||||||
|
useDataProvider,
|
||||||
|
useNotify,
|
||||||
|
useTranslate,
|
||||||
|
useRefresh,
|
||||||
|
} from 'react-admin'
|
||||||
|
import {
|
||||||
|
Button,
|
||||||
|
Dialog,
|
||||||
|
DialogActions,
|
||||||
|
DialogContent,
|
||||||
|
DialogTitle,
|
||||||
|
TextField,
|
||||||
|
CircularProgress,
|
||||||
|
} from '@material-ui/core'
|
||||||
|
import { closeSaveQueueDialog } from '../actions'
|
||||||
|
import { useHistory } from 'react-router-dom'
|
||||||
|
|
||||||
|
export const SaveQueueDialog = () => {
|
||||||
|
const dispatch = useDispatch()
|
||||||
|
const { open } = useSelector((state) => state.saveQueueDialog)
|
||||||
|
const queue = useSelector((state) => state.player.queue)
|
||||||
|
const [name, setName] = useState('')
|
||||||
|
const dataProvider = useDataProvider()
|
||||||
|
const notify = useNotify()
|
||||||
|
const translate = useTranslate()
|
||||||
|
const history = useHistory()
|
||||||
|
const [isSaving, setIsSaving] = useState(false)
|
||||||
|
const refresh = useRefresh()
|
||||||
|
|
||||||
|
const handleClose = useCallback(
|
||||||
|
(e) => {
|
||||||
|
setName('')
|
||||||
|
dispatch(closeSaveQueueDialog())
|
||||||
|
e.stopPropagation()
|
||||||
|
},
|
||||||
|
[dispatch],
|
||||||
|
)
|
||||||
|
|
||||||
|
const handleSave = useCallback(() => {
|
||||||
|
setIsSaving(true)
|
||||||
|
const ids = queue.map((item) => item.trackId)
|
||||||
|
dataProvider
|
||||||
|
.create('playlist', { data: { name } })
|
||||||
|
.then((res) => {
|
||||||
|
const playlistId = res.data.id
|
||||||
|
if (ids.length) {
|
||||||
|
return dataProvider
|
||||||
|
.create('playlistTrack', {
|
||||||
|
data: { ids },
|
||||||
|
filter: { playlist_id: playlistId },
|
||||||
|
})
|
||||||
|
.then(() => res)
|
||||||
|
}
|
||||||
|
return res
|
||||||
|
})
|
||||||
|
.then((res) => {
|
||||||
|
notify('ra.notification.created', 'info', { smart_count: 1 })
|
||||||
|
dispatch(closeSaveQueueDialog())
|
||||||
|
refresh()
|
||||||
|
history.push(`/playlist/${res.data.id}/show`)
|
||||||
|
})
|
||||||
|
.catch(() => notify('ra.page.error', { type: 'warning' }))
|
||||||
|
.finally(() => setIsSaving(false))
|
||||||
|
}, [dataProvider, dispatch, notify, queue, name, history, refresh])
|
||||||
|
|
||||||
|
const handleKeyPress = useCallback(
|
||||||
|
(e) => {
|
||||||
|
if (e.key === 'Enter' && name.trim() !== '') {
|
||||||
|
handleSave()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[handleSave, name],
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog
|
||||||
|
open={open}
|
||||||
|
onClose={isSaving ? undefined : handleClose}
|
||||||
|
aria-labelledby="save-queue-dialog"
|
||||||
|
fullWidth={true}
|
||||||
|
maxWidth={'sm'}
|
||||||
|
>
|
||||||
|
<DialogTitle id="save-queue-dialog">
|
||||||
|
{translate('resources.playlist.actions.saveQueue', { _: 'Save Queue' })}
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogContent>
|
||||||
|
<TextField
|
||||||
|
value={name}
|
||||||
|
onChange={(e) => setName(e.target.value)}
|
||||||
|
onKeyPress={handleKeyPress}
|
||||||
|
autoFocus
|
||||||
|
fullWidth
|
||||||
|
variant={'outlined'}
|
||||||
|
label={translate('resources.playlist.fields.name')}
|
||||||
|
disabled={isSaving}
|
||||||
|
/>
|
||||||
|
</DialogContent>
|
||||||
|
<DialogActions>
|
||||||
|
<Button onClick={handleClose} color="primary" disabled={isSaving}>
|
||||||
|
{translate('ra.action.cancel')}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={handleSave}
|
||||||
|
color="primary"
|
||||||
|
disabled={name.trim() === '' || isSaving}
|
||||||
|
data-testid="save-queue-save"
|
||||||
|
startIcon={isSaving ? <CircularProgress size={20} /> : null}
|
||||||
|
>
|
||||||
|
{translate('ra.action.save')}
|
||||||
|
</Button>
|
||||||
|
</DialogActions>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
import * as React from 'react'
|
||||||
|
import { TestContext } from 'ra-test'
|
||||||
|
import { DataProviderContext } from 'react-admin'
|
||||||
|
import {
|
||||||
|
cleanup,
|
||||||
|
fireEvent,
|
||||||
|
render,
|
||||||
|
waitFor,
|
||||||
|
screen,
|
||||||
|
} from '@testing-library/react'
|
||||||
|
import { SaveQueueDialog } from './SaveQueueDialog'
|
||||||
|
import { describe, afterEach, it, expect, vi, beforeAll } from 'vitest'
|
||||||
|
|
||||||
|
const queue = [{ trackId: 'song-1' }, { trackId: 'song-2' }]
|
||||||
|
|
||||||
|
const createTestUtils = (mockDataProvider) =>
|
||||||
|
render(
|
||||||
|
<DataProviderContext.Provider value={mockDataProvider}>
|
||||||
|
<TestContext
|
||||||
|
initialState={{
|
||||||
|
saveQueueDialog: { open: true },
|
||||||
|
player: { queue },
|
||||||
|
admin: { ui: { optimistic: false } },
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SaveQueueDialog />
|
||||||
|
</TestContext>
|
||||||
|
</DataProviderContext.Provider>,
|
||||||
|
)
|
||||||
|
|
||||||
|
// Mock useHistory to update window.location.hash on push
|
||||||
|
vi.mock('react-router-dom', async (importOriginal) => {
|
||||||
|
const actual = await importOriginal()
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
useHistory: () => ({
|
||||||
|
push: (url) => {
|
||||||
|
window.location.hash = `#${url}`
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
beforeAll(() => {
|
||||||
|
// No need to patch pushState anymore
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('SaveQueueDialog', () => {
|
||||||
|
afterEach(cleanup)
|
||||||
|
|
||||||
|
it('creates playlist and saves queue', async () => {
|
||||||
|
const mockDataProvider = {
|
||||||
|
create: vi
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValueOnce({ data: { id: 'created-id' } })
|
||||||
|
.mockResolvedValueOnce({ data: { id: 'pt-id' } }),
|
||||||
|
}
|
||||||
|
|
||||||
|
createTestUtils(mockDataProvider)
|
||||||
|
|
||||||
|
fireEvent.change(screen.getByRole('textbox'), {
|
||||||
|
target: { value: 'my playlist' },
|
||||||
|
})
|
||||||
|
fireEvent.click(screen.getByTestId('save-queue-save'))
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockDataProvider.create).toHaveBeenNthCalledWith(1, 'playlist', {
|
||||||
|
data: { name: 'my playlist' },
|
||||||
|
})
|
||||||
|
})
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockDataProvider.create).toHaveBeenNthCalledWith(
|
||||||
|
2,
|
||||||
|
'playlistTrack',
|
||||||
|
{
|
||||||
|
data: { ids: ['song-1', 'song-2'] },
|
||||||
|
filter: { playlist_id: 'created-id' },
|
||||||
|
},
|
||||||
|
)
|
||||||
|
})
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(window.location.hash).toBe('#/playlist/created-id/show')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('disables save button when name is empty', () => {
|
||||||
|
const mockDataProvider = { create: vi.fn() }
|
||||||
|
createTestUtils(mockDataProvider)
|
||||||
|
expect(screen.getByTestId('save-queue-save')).toBeDisabled()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
export * from './AboutDialog'
|
export * from './AboutDialog'
|
||||||
export * from './SelectPlaylistInput'
|
export * from './SelectPlaylistInput'
|
||||||
export * from './ListenBrainzTokenDialog'
|
export * from './ListenBrainzTokenDialog'
|
||||||
|
export * from './SaveQueueDialog'
|
||||||
export * from './Dialogs'
|
export * from './Dialogs'
|
||||||
|
|||||||
@@ -192,6 +192,7 @@
|
|||||||
"selectPlaylist": "Select a playlist:",
|
"selectPlaylist": "Select a playlist:",
|
||||||
"addNewPlaylist": "Create \"%{name}\"",
|
"addNewPlaylist": "Create \"%{name}\"",
|
||||||
"export": "Export",
|
"export": "Export",
|
||||||
|
"saveQueue": "Save Queue to Playlist",
|
||||||
"makePublic": "Make Public",
|
"makePublic": "Make Public",
|
||||||
"makePrivate": "Make Private"
|
"makePrivate": "Make Private"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ import {
|
|||||||
EXTENDED_INFO_CLOSE,
|
EXTENDED_INFO_CLOSE,
|
||||||
LISTENBRAINZ_TOKEN_OPEN,
|
LISTENBRAINZ_TOKEN_OPEN,
|
||||||
LISTENBRAINZ_TOKEN_CLOSE,
|
LISTENBRAINZ_TOKEN_CLOSE,
|
||||||
|
SAVE_QUEUE_OPEN,
|
||||||
|
SAVE_QUEUE_CLOSE,
|
||||||
SHARE_MENU_OPEN,
|
SHARE_MENU_OPEN,
|
||||||
SHARE_MENU_CLOSE,
|
SHARE_MENU_CLOSE,
|
||||||
} from '../actions'
|
} from '../actions'
|
||||||
@@ -169,3 +171,18 @@ export const listenBrainzTokenDialogReducer = (
|
|||||||
return previousState
|
return previousState
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const saveQueueDialogReducer = (
|
||||||
|
previousState = { open: false },
|
||||||
|
payload,
|
||||||
|
) => {
|
||||||
|
const { type } = payload
|
||||||
|
switch (type) {
|
||||||
|
case SAVE_QUEUE_OPEN:
|
||||||
|
return { ...previousState, open: true }
|
||||||
|
case SAVE_QUEUE_CLOSE:
|
||||||
|
return { ...previousState, open: false }
|
||||||
|
default:
|
||||||
|
return previousState
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user