From 1779e345c40fdd5d4afed705251fffb62fb120af Mon Sep 17 00:00:00 2001 From: Cristan Beskid <596554+sferra@users.noreply.github.com> Date: Wed, 6 Mar 2024 23:53:45 +0100 Subject: [PATCH 1/3] Add functionality for adding albums to existing playlists --- .../app/app/components/AlbumView/index.tsx | 25 ++++++++++++++++--- .../containers/AlbumViewContainer/hooks.ts | 24 +++++++++++++++++- packages/i18n/src/locales/en.json | 3 ++- 3 files changed, 47 insertions(+), 5 deletions(-) diff --git a/packages/app/app/components/AlbumView/index.tsx b/packages/app/app/components/AlbumView/index.tsx index 20ef83fea7..c594f98f8a 100644 --- a/packages/app/app/components/AlbumView/index.tsx +++ b/packages/app/app/components/AlbumView/index.tsx @@ -1,9 +1,9 @@ import React from 'react'; import Img from 'react-image'; import _ from 'lodash'; -import { Dimmer, Icon, Loader } from 'semantic-ui-react'; +import { Dimmer, Icon, Loader, Dropdown } from 'semantic-ui-react'; import { useTranslation } from 'react-i18next'; -import { Loader as NuclearLoader, ContextPopup, PopupButton } from '@nuclear/ui'; +import { Loader as NuclearLoader, ContextPopup, PopupButton, PopupDropdown } from '@nuclear/ui'; import styles from './styles.scss'; import artPlaceholder from '../../../resources/media/art_placeholder.png'; import TrackTableContainer from '../../containers/TrackTableContainer'; @@ -15,9 +15,11 @@ type AlbumViewProps = { searchAlbumArtist: React.MouseEventHandler; addAlbumToDownloads: React.MouseEventHandler; addAlbumToQueue: React.MouseEventHandler; + addAlbumToPlaylist: (playlistName: string) => void; playAll: React.MouseEventHandler; removeFavoriteAlbum: React.MouseEventHandler; addFavoriteAlbum: React.MouseEventHandler; + playlistNames: string[]; } export const AlbumView: React.FC = ({ @@ -26,9 +28,11 @@ export const AlbumView: React.FC = ({ searchAlbumArtist, addAlbumToDownloads, addAlbumToQueue, + addAlbumToPlaylist, playAll, removeFavoriteAlbum, - addFavoriteAlbum + addFavoriteAlbum, + playlistNames }) => { const { t } = useTranslation('album'); const release_date: Date = new Date(album.year); @@ -124,6 +128,21 @@ export const AlbumView: React.FC = ({ icon='download' label={t('download')} /> + + { + playlistNames?.map((playlistName, i) => ( + addAlbumToPlaylist(playlistName)} + > + + {playlistName} + + )) + } + diff --git a/packages/app/app/containers/AlbumViewContainer/hooks.ts b/packages/app/app/containers/AlbumViewContainer/hooks.ts index b6eec5507f..b3d7c585f3 100644 --- a/packages/app/app/containers/AlbumViewContainer/hooks.ts +++ b/packages/app/app/containers/AlbumViewContainer/hooks.ts @@ -14,6 +14,9 @@ import { pluginsSelectors } from '../../selectors/plugins'; import { searchSelectors } from '../../selectors/search'; import { stringDurationToSeconds } from '../../utils'; import { AlbumDetailsState } from '../../reducers/search'; +import { playlistsSelectors } from '../../selectors/playlists'; +import * as playlistActions from '../../actions/playlists'; +import { PlaylistTrack } from '@nuclear/core'; export const useAlbumViewProps = () => { const dispatch = useDispatch(); @@ -24,6 +27,7 @@ export const useAlbumViewProps = () => { // TODO replace this any with a proper type const plugins: any = useSelector(pluginsSelectors.plugins); const favoriteAlbums = useSelector(favoritesSelectors.albums); + const localPlaylists = useSelector(playlistsSelectors.localPlaylists); const albumFromFavorites = favoriteAlbums.find(album => album.id === albumId); const album = albumFromFavorites || albumDetails[albumId]; @@ -74,6 +78,20 @@ export const useAlbumViewProps = () => { }); }, [album, dispatch]); + const addAlbumToPlaylist = useCallback(async (playlistName: string) => { + const tracksWithId: PlaylistTrack[] = []; + await album?.tracklist.forEach((track: PlaylistTrack) => tracksWithId.push(safeAddUuid(track))); + const originalPlaylist = localPlaylists.data?.find(playlist => playlist.name === playlistName); + const playlistWithAlbumTracks = { + ...originalPlaylist, + tracks: [ + ...originalPlaylist.tracks, + ...tracksWithId + ] + }; + dispatch(playlistActions.updatePlaylist(playlistWithAlbumTracks)); + }, [album, localPlaylists, dispatch]); + const playAll = useCallback(async () => { dispatch(QueueActions.clearQueue()); await addAlbumToQueue(); @@ -89,14 +107,18 @@ export const useAlbumViewProps = () => { dispatch(FavoritesActions.removeFavoriteAlbum(album)); }, [album, dispatch]); + const playlistNames = localPlaylists.data?.map(playlist => playlist.name); + return { album, isFavorite, searchAlbumArtist, addAlbumToDownloads, addAlbumToQueue, + addAlbumToPlaylist, playAll, addFavoriteAlbum, - removeFavoriteAlbum + removeFavoriteAlbum, + playlistNames }; }; diff --git a/packages/i18n/src/locales/en.json b/packages/i18n/src/locales/en.json index a142ca28b2..4455f53d58 100644 --- a/packages/i18n/src/locales/en.json +++ b/packages/i18n/src/locales/en.json @@ -7,6 +7,7 @@ "genre": "Genre:", "play": "Play", "queue": "Add album to queue", + "add-to-playlist": "Add album to playlist", "tracks": "Tracks:", "year": "Year:" }, @@ -461,4 +462,4 @@ "sign-in-button": "Sign in" } } -} \ No newline at end of file +} From b85d199ec373b102788a745e599add587ae9b5ab Mon Sep 17 00:00:00 2001 From: Cristan Beskid <596554+sferra@users.noreply.github.com> Date: Thu, 7 Mar 2024 23:39:59 +0100 Subject: [PATCH 2/3] Add test for adding an album to an existing playlist --- .../AlbumViewContainer.test.tsx | 23 +++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/packages/app/app/containers/AlbumViewContainer/AlbumViewContainer.test.tsx b/packages/app/app/containers/AlbumViewContainer/AlbumViewContainer.test.tsx index cc11e21f5d..eb6f988658 100644 --- a/packages/app/app/containers/AlbumViewContainer/AlbumViewContainer.test.tsx +++ b/packages/app/app/containers/AlbumViewContainer/AlbumViewContainer.test.tsx @@ -256,6 +256,29 @@ describe('Album view container', () => { ]); }); + it('should add album tracks to an existing playlist using the album menu popup', async () => { + const { component, store } = mountComponent( + buildStoreState() + .withAlbumDetails() + .withPlaylists([{ + id: 'test playlist id', + name: 'test playlist', + tracks: [] + }]) + .build() + ); + await waitFor(() => component.getByTestId('more-button').click()); + await waitFor(() => component.getByText(/Add album to playlist/i).click()); + await waitFor(() => component.getByText('test playlist').click()); + + const state = store.getState(); + expect(state.playlists.localPlaylists.data[0].tracks).toEqual([ + expect.objectContaining({ uuid: 'track-1-id' }), + expect.objectContaining({ uuid: 'track-2-id' }), + expect.objectContaining({ uuid: 'track-3-id' }) + ]); + }); + const mountComponent = mountedComponentFactory( ['/album/test-album-id'], buildStoreState() From f24ddc7552711a5df62052aa026ec73577b3a075 Mon Sep 17 00:00:00 2001 From: Cristan Beskid <596554+sferra@users.noreply.github.com> Date: Mon, 18 Mar 2024 00:35:42 +0100 Subject: [PATCH 3/3] Add functionality for adding albums to new playlists --- .../app/app/components/AlbumView/index.tsx | 37 +++++++++++++-- .../AlbumViewContainer.test.tsx | 31 ++++++++++-- .../containers/AlbumViewContainer/hooks.ts | 47 ++++++++++++------- packages/i18n/src/locales/en.json | 9 +++- 4 files changed, 96 insertions(+), 28 deletions(-) diff --git a/packages/app/app/components/AlbumView/index.tsx b/packages/app/app/components/AlbumView/index.tsx index c594f98f8a..39d51e787e 100644 --- a/packages/app/app/components/AlbumView/index.tsx +++ b/packages/app/app/components/AlbumView/index.tsx @@ -1,9 +1,9 @@ -import React from 'react'; +import React, { useState } from 'react'; import Img from 'react-image'; import _ from 'lodash'; import { Dimmer, Icon, Loader, Dropdown } from 'semantic-ui-react'; import { useTranslation } from 'react-i18next'; -import { Loader as NuclearLoader, ContextPopup, PopupButton, PopupDropdown } from '@nuclear/ui'; +import { Loader as NuclearLoader, ContextPopup, PopupButton, PopupDropdown, InputDialog } from '@nuclear/ui'; import styles from './styles.scss'; import artPlaceholder from '../../../resources/media/art_placeholder.png'; import TrackTableContainer from '../../containers/TrackTableContainer'; @@ -15,11 +15,12 @@ type AlbumViewProps = { searchAlbumArtist: React.MouseEventHandler; addAlbumToDownloads: React.MouseEventHandler; addAlbumToQueue: React.MouseEventHandler; - addAlbumToPlaylist: (playlistName: string) => void; playAll: React.MouseEventHandler; removeFavoriteAlbum: React.MouseEventHandler; addFavoriteAlbum: React.MouseEventHandler; + addAlbumToPlaylist: (playlistName: string) => void; playlistNames: string[]; + addAlbumToNewPlaylist?: (playlistName: string) => void; } export const AlbumView: React.FC = ({ @@ -28,13 +29,18 @@ export const AlbumView: React.FC = ({ searchAlbumArtist, addAlbumToDownloads, addAlbumToQueue, - addAlbumToPlaylist, playAll, removeFavoriteAlbum, addFavoriteAlbum, - playlistNames + addAlbumToPlaylist, + playlistNames, + addAlbumToNewPlaylist }) => { const { t } = useTranslation('album'); + const [isCreatePlaylistDialogOpen, setIsCreatePlaylistDialogOpen] = useState(false); + const displayPlaylistCreationDialog = () => setIsCreatePlaylistDialogOpen(true); + const hidePlaylistCreationDialog = () => setIsCreatePlaylistDialogOpen(false); + const release_date: Date = new Date(album.year); return
@@ -130,6 +136,7 @@ export const AlbumView: React.FC = ({ /> { playlistNames?.map((playlistName, i) => ( @@ -142,8 +149,28 @@ export const AlbumView: React.FC = ({ )) } + displayPlaylistCreationDialog()} + data-testid='playlist-popup-create-playlist' + > + + {t('create-playlist')} + + hidePlaylistCreationDialog()} + header={

{t('create-playlist-dialog-title')}

} + placeholder={t('create-playlist-dialog-placeholder')} + acceptLabel={t('create-playlist-dialog-accept')} + cancelLabel={t('create-playlist-dialog-cancel')} + onAccept={(input) => { + addAlbumToNewPlaylist(input); + }} + initialString={`${album.artist} - ${album.title}`} + testIdPrefix='create-playlist-dialog' + />
diff --git a/packages/app/app/containers/AlbumViewContainer/AlbumViewContainer.test.tsx b/packages/app/app/containers/AlbumViewContainer/AlbumViewContainer.test.tsx index eb6f988658..7e93e92bc7 100644 --- a/packages/app/app/containers/AlbumViewContainer/AlbumViewContainer.test.tsx +++ b/packages/app/app/containers/AlbumViewContainer/AlbumViewContainer.test.tsx @@ -2,6 +2,7 @@ import { waitFor } from '@testing-library/react'; import { mountedComponentFactory, setupI18Next } from '../../../test/testUtils'; import { buildStoreState } from '../../../test/storeBuilders'; import PlayerBarContainer from '../PlayerBarContainer'; +import userEvent from '@testing-library/user-event'; describe('Album view container', () => { beforeAll(() => { @@ -267,9 +268,9 @@ describe('Album view container', () => { }]) .build() ); - await waitFor(() => component.getByTestId('more-button').click()); - await waitFor(() => component.getByText(/Add album to playlist/i).click()); - await waitFor(() => component.getByText('test playlist').click()); + userEvent.click(component.getByTestId('more-button')); + userEvent.click(component.getByTestId('add-album-to-playlist')); + userEvent.click(component.getByText('test playlist')); const state = store.getState(); expect(state.playlists.localPlaylists.data[0].tracks).toEqual([ @@ -279,6 +280,30 @@ describe('Album view container', () => { ]); }); + it('should add album tracks to a new playlist using the album menu popup', async () => { + const { component, store } = mountComponent(); + userEvent.click(component.getByTestId('more-button')); + userEvent.click(component.getByTestId('add-album-to-playlist')); + userEvent.click(component.getByTestId('playlist-popup-create-playlist')); + + expect(component.getByTestId('create-playlist-dialog-input')).toBeVisible(); + expect(component.getByTestId('create-playlist-dialog-accept')).toBeVisible(); + expect(component.getByTestId('create-playlist-dialog-cancel')).toBeVisible(); + + userEvent.click(component.getByTestId('create-playlist-dialog-accept')); + + const state = store.getState(); + expect(state.playlists.localPlaylists.data).toHaveLength(1); + + const firstPlaylist = state.playlists.localPlaylists.data[0]; + expect(firstPlaylist.name).toBe('test artist - test album'); + expect(firstPlaylist.tracks).toEqual([ + expect.objectContaining({ uuid: 'track-1-id' }), + expect.objectContaining({ uuid: 'track-2-id' }), + expect.objectContaining({ uuid: 'track-3-id' }) + ]); + }); + const mountComponent = mountedComponentFactory( ['/album/test-album-id'], buildStoreState() diff --git a/packages/app/app/containers/AlbumViewContainer/hooks.ts b/packages/app/app/containers/AlbumViewContainer/hooks.ts index b3d7c585f3..583dde7446 100644 --- a/packages/app/app/containers/AlbumViewContainer/hooks.ts +++ b/packages/app/app/containers/AlbumViewContainer/hooks.ts @@ -15,8 +15,8 @@ import { searchSelectors } from '../../selectors/search'; import { stringDurationToSeconds } from '../../utils'; import { AlbumDetailsState } from '../../reducers/search'; import { playlistsSelectors } from '../../selectors/playlists'; -import * as playlistActions from '../../actions/playlists'; -import { PlaylistTrack } from '@nuclear/core'; +import * as PlaylistActions from '../../actions/playlists'; +import { PlaylistTrack, Track } from '@nuclear/core'; export const useAlbumViewProps = () => { const dispatch = useDispatch(); @@ -78,20 +78,6 @@ export const useAlbumViewProps = () => { }); }, [album, dispatch]); - const addAlbumToPlaylist = useCallback(async (playlistName: string) => { - const tracksWithId: PlaylistTrack[] = []; - await album?.tracklist.forEach((track: PlaylistTrack) => tracksWithId.push(safeAddUuid(track))); - const originalPlaylist = localPlaylists.data?.find(playlist => playlist.name === playlistName); - const playlistWithAlbumTracks = { - ...originalPlaylist, - tracks: [ - ...originalPlaylist.tracks, - ...tracksWithId - ] - }; - dispatch(playlistActions.updatePlaylist(playlistWithAlbumTracks)); - }, [album, localPlaylists, dispatch]); - const playAll = useCallback(async () => { dispatch(QueueActions.clearQueue()); await addAlbumToQueue(); @@ -109,16 +95,41 @@ export const useAlbumViewProps = () => { const playlistNames = localPlaylists.data?.map(playlist => playlist.name); + function getPlaylistTracks() { + const tracksWithId: PlaylistTrack[] = []; + album.tracklist?.forEach((track: Track) => tracksWithId.push(safeAddUuid(track))); + return tracksWithId; + } + + const addAlbumToPlaylist = useCallback(async (playlistName: string) => { + const tracksWithId: PlaylistTrack[] = getPlaylistTracks(); + const originalPlaylist = localPlaylists.data?.find(playlist => playlist.name === playlistName); + const playlistWithAlbumTracks = { + ...originalPlaylist, + tracks: [ + ...originalPlaylist.tracks, + ...tracksWithId + ] + }; + dispatch(PlaylistActions.updatePlaylist(playlistWithAlbumTracks)); + }, [album, localPlaylists, dispatch]); + + const addAlbumToNewPlaylist = useCallback(async (playlistName: string) => { + dispatch(PlaylistActions.addPlaylist(getPlaylistTracks(), playlistName)); + }, [album, dispatch]); + + return { album, isFavorite, searchAlbumArtist, addAlbumToDownloads, addAlbumToQueue, - addAlbumToPlaylist, playAll, addFavoriteAlbum, removeFavoriteAlbum, - playlistNames + addAlbumToPlaylist, + playlistNames, + addAlbumToNewPlaylist }; }; diff --git a/packages/i18n/src/locales/en.json b/packages/i18n/src/locales/en.json index 4455f53d58..6f6a9a508d 100644 --- a/packages/i18n/src/locales/en.json +++ b/packages/i18n/src/locales/en.json @@ -7,9 +7,14 @@ "genre": "Genre:", "play": "Play", "queue": "Add album to queue", - "add-to-playlist": "Add album to playlist", "tracks": "Tracks:", - "year": "Year:" + "year": "Year:", + "add-to-playlist": "Add album to playlist", + "create-playlist": "Create new playlist", + "create-playlist-dialog-title": "Input playlist name:", + "create-playlist-dialog-placeholder": "Playlist name...", + "create-playlist-dialog-accept": "Save", + "create-playlist-dialog-cancel": "Cancel" }, "app": { "collection": "Collection",