diff --git a/resources/i18n/pt-br.json b/resources/i18n/pt-br.json index 974a628f1..4b240bcff 100644 --- a/resources/i18n/pt-br.json +++ b/resources/i18n/pt-br.json @@ -191,6 +191,7 @@ "selectPlaylist": "Selecione a playlist:", "addNewPlaylist": "Criar \"%{name}\"", "export": "Exportar", + "saveQueue": "Salvar fila em nova Playlist", "makePublic": "Pública", "makePrivate": "Pessoal" }, diff --git a/ui/src/App.jsx b/ui/src/App.jsx index a3a34a5f3..1b89f7b8c 100644 --- a/ui/src/App.jsx +++ b/ui/src/App.jsx @@ -22,6 +22,7 @@ import { addToPlaylistDialogReducer, expandInfoDialogReducer, listenBrainzTokenDialogReducer, + saveQueueDialogReducer, playerReducer, albumViewReducer, activityReducer, @@ -62,6 +63,7 @@ const adminStore = createAdminStore({ downloadMenuDialog: downloadMenuDialogReducer, expandInfoDialog: expandInfoDialogReducer, listenBrainzTokenDialog: listenBrainzTokenDialogReducer, + saveQueueDialog: saveQueueDialogReducer, shareDialog: shareDialogReducer, activity: activityReducer, settings: settingsReducer, diff --git a/ui/src/actions/dialogs.js b/ui/src/actions/dialogs.js index 8fac6076a..dea4d6203 100644 --- a/ui/src/actions/dialogs.js +++ b/ui/src/actions/dialogs.js @@ -8,6 +8,8 @@ export const EXTENDED_INFO_OPEN = 'EXTENDED_INFO_OPEN' export const EXTENDED_INFO_CLOSE = 'EXTENDED_INFO_CLOSE' export const LISTENBRAINZ_TOKEN_OPEN = 'LISTENBRAINZ_TOKEN_OPEN' 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_ARTIST = 'artist' export const DOWNLOAD_MENU_PLAY = 'playlist' @@ -76,3 +78,11 @@ export const openListenBrainzTokenDialog = () => ({ export const closeListenBrainzTokenDialog = () => ({ type: LISTENBRAINZ_TOKEN_CLOSE, }) + +export const openSaveQueueDialog = () => ({ + type: SAVE_QUEUE_OPEN, +}) + +export const closeSaveQueueDialog = () => ({ + type: SAVE_QUEUE_CLOSE, +}) diff --git a/ui/src/audioplayer/PlayerToolbar.jsx b/ui/src/audioplayer/PlayerToolbar.jsx index d3645f5b0..82f9e36f3 100644 --- a/ui/src/audioplayer/PlayerToolbar.jsx +++ b/ui/src/audioplayer/PlayerToolbar.jsx @@ -1,32 +1,120 @@ import React, { useCallback } from 'react' +import { useDispatch } from 'react-redux' import { useGetOne } from 'react-admin' 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 { openSaveQueueDialog } from '../actions' import { keyMap } from '../hotkeys' +import { makeStyles } from '@material-ui/core/styles' -const Placeholder = () => +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 [toggleLove, toggling] = useToggleLove('song', data) + const isDesktop = useMediaQuery('(min-width:810px)') + const classes = useStyles() const handlers = { 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 = ( + + + + ) + + const loveButton = ( + + ) + return ( <> - + {isDesktop ? ( +
  • + {saveQueueButton} + {loveButton} +
  • + ) : ( + <> +
  • {saveQueueButton}
  • +
  • {loveButton}
  • + + )} ) } -const PlayerToolbar = ({ id, isRadio }) => - id && !isRadio ? : - export default PlayerToolbar diff --git a/ui/src/audioplayer/PlayerToolbar.test.jsx b/ui/src/audioplayer/PlayerToolbar.test.jsx new file mode 100644 index 000000000..d0368b0f0 --- /dev/null +++ b/ui/src/audioplayer/PlayerToolbar.test.jsx @@ -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 }) => ( + + ), + useToggleLove: vi.fn(), +})) + +vi.mock('../actions', () => ({ + openSaveQueueDialog: vi.fn(), +})) + +vi.mock('react-hotkeys', () => ({ + GlobalHotKeys: () =>
    , +})) + +describe('', () => { + 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() + + // 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() + + 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() + + const loveButton = screen.getByTestId('love-button') + expect(loveButton).toBeDisabled() + }) + + it('opens save queue dialog when save button is clicked', () => { + render() + + 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() + + // 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() + + 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() + + 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() + expect(screen.getByTestId('global-hotkeys')).toBeInTheDocument() + + // Cleanup and test mobile layout + cleanup() + useMediaQuery.mockReturnValue(false) + render() + expect(screen.getByTestId('global-hotkeys')).toBeInTheDocument() + }) + + it('disables buttons when id is not provided', () => { + render() + + const loveButton = screen.getByTestId('love-button') + expect(loveButton).toBeDisabled() + }) + }) +}) diff --git a/ui/src/common/CollapsibleComment.jsx b/ui/src/common/CollapsibleComment.jsx index 8660d0492..77750e7f4 100644 --- a/ui/src/common/CollapsibleComment.jsx +++ b/ui/src/common/CollapsibleComment.jsx @@ -25,7 +25,10 @@ export const CollapsibleComment = ({ record }) => { const classes = useStyles() const [expanded, setExpanded] = useState(false) - const lines = record.comment.split('\n') + const lines = useMemo( + () => record.comment?.split('\n') || [], + [record.comment], + ) const formatted = useMemo(() => { return lines.map((line, idx) => ( diff --git a/ui/src/dialogs/Dialogs.jsx b/ui/src/dialogs/Dialogs.jsx index 74f5a094d..f37c55823 100644 --- a/ui/src/dialogs/Dialogs.jsx +++ b/ui/src/dialogs/Dialogs.jsx @@ -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) => ( <> + diff --git a/ui/src/dialogs/SaveQueueDialog.jsx b/ui/src/dialogs/SaveQueueDialog.jsx new file mode 100644 index 000000000..69f07dab7 --- /dev/null +++ b/ui/src/dialogs/SaveQueueDialog.jsx @@ -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 ( + + + {translate('resources.playlist.actions.saveQueue', { _: 'Save Queue' })} + + + setName(e.target.value)} + onKeyPress={handleKeyPress} + autoFocus + fullWidth + variant={'outlined'} + label={translate('resources.playlist.fields.name')} + disabled={isSaving} + /> + + + + + + + ) +} diff --git a/ui/src/dialogs/SaveQueueDialog.test.jsx b/ui/src/dialogs/SaveQueueDialog.test.jsx new file mode 100644 index 000000000..c3a91385f --- /dev/null +++ b/ui/src/dialogs/SaveQueueDialog.test.jsx @@ -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( + + + + + , + ) + +// 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() + }) +}) diff --git a/ui/src/dialogs/index.js b/ui/src/dialogs/index.js index 7f71529f8..86586aef0 100644 --- a/ui/src/dialogs/index.js +++ b/ui/src/dialogs/index.js @@ -1,4 +1,5 @@ export * from './AboutDialog' export * from './SelectPlaylistInput' export * from './ListenBrainzTokenDialog' +export * from './SaveQueueDialog' export * from './Dialogs' diff --git a/ui/src/i18n/en.json b/ui/src/i18n/en.json index ab8712152..64b264e45 100644 --- a/ui/src/i18n/en.json +++ b/ui/src/i18n/en.json @@ -192,6 +192,7 @@ "selectPlaylist": "Select a playlist:", "addNewPlaylist": "Create \"%{name}\"", "export": "Export", + "saveQueue": "Save Queue to Playlist", "makePublic": "Make Public", "makePrivate": "Make Private" }, diff --git a/ui/src/reducers/dialogReducer.js b/ui/src/reducers/dialogReducer.js index 04f235c5f..e1a77f100 100644 --- a/ui/src/reducers/dialogReducer.js +++ b/ui/src/reducers/dialogReducer.js @@ -13,6 +13,8 @@ import { EXTENDED_INFO_CLOSE, LISTENBRAINZ_TOKEN_OPEN, LISTENBRAINZ_TOKEN_CLOSE, + SAVE_QUEUE_OPEN, + SAVE_QUEUE_CLOSE, SHARE_MENU_OPEN, SHARE_MENU_CLOSE, } from '../actions' @@ -169,3 +171,18 @@ export const listenBrainzTokenDialogReducer = ( 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 + } +}