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:
@@ -2,10 +2,12 @@ import { AddToPlaylistDialog } from './AddToPlaylistDialog'
|
||||
import DownloadMenuDialog from './DownloadMenuDialog'
|
||||
import { HelpDialog } from './HelpDialog'
|
||||
import { ShareDialog } from './ShareDialog'
|
||||
import { SaveQueueDialog } from './SaveQueueDialog'
|
||||
|
||||
export const Dialogs = (props) => (
|
||||
<>
|
||||
<AddToPlaylistDialog />
|
||||
<SaveQueueDialog />
|
||||
<DownloadMenuDialog />
|
||||
<HelpDialog />
|
||||
<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 './SelectPlaylistInput'
|
||||
export * from './ListenBrainzTokenDialog'
|
||||
export * from './SaveQueueDialog'
|
||||
export * from './Dialogs'
|
||||
|
||||
Reference in New Issue
Block a user