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:
Deluan Quintão
2025-05-23 22:04:18 -04:00
committed by GitHub
parent 370f8ba293
commit 514aceb785
12 changed files with 510 additions and 11 deletions
+2
View File
@@ -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 />
+117
View File
@@ -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>
)
}
+91
View File
@@ -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
View File
@@ -1,4 +1,5 @@
export * from './AboutDialog'
export * from './SelectPlaylistInput'
export * from './ListenBrainzTokenDialog'
export * from './SaveQueueDialog'
export * from './Dialogs'