From 21e3dc4293da86d7a95fcc6966acb679ef0541cb Mon Sep 17 00:00:00 2001 From: Bastien DUMONT Date: Sun, 22 Jun 2025 16:07:09 +0200 Subject: [PATCH 1/3] Use zustand instead of redux --- package.json | 5 +- src/App.tsx | 14 +- src/components/Player/Player.spec.tsx | 60 ++++--- src/components/Player/Player.tsx | 14 +- src/index.tsx | 6 +- .../PlaylistDetail/PlaylistDetail.spec.tsx | 96 +++++++---- src/pages/PlaylistDetail/PlaylistDetail.tsx | 17 +- .../PlaylistDetail/SongItem/SongItem.tsx | 7 +- src/store/hooks.ts | 10 -- src/store/reducers/currentSong.slice.ts | 33 ---- src/store/reducers/currentSong.spec.ts | 68 ++++++-- src/store/reducers/playlistDetail.slice.ts | 55 ------- src/store/reducers/userPlaylists.slice.ts | 54 ------- src/store/store.ts | 40 ----- src/store/test-utils.ts | 92 +++++++++++ src/store/zustand-store.ts | 151 ++++++++++++++++++ yarn.lock | 65 +------- 17 files changed, 422 insertions(+), 365 deletions(-) delete mode 100644 src/store/hooks.ts delete mode 100644 src/store/reducers/currentSong.slice.ts delete mode 100644 src/store/reducers/playlistDetail.slice.ts delete mode 100644 src/store/reducers/userPlaylists.slice.ts delete mode 100644 src/store/store.ts create mode 100644 src/store/test-utils.ts create mode 100644 src/store/zustand-store.ts diff --git a/package.json b/package.json index 60878ba..2f42edf 100644 --- a/package.json +++ b/package.json @@ -16,7 +16,6 @@ }, "dependencies": { "@biomejs/biome": "1.9.4", - "@reduxjs/toolkit": "^2.8.2", "@testing-library/react": "^16.3.0", "@testing-library/user-event": "^14.6.1", "axios": "^1.9.0", @@ -24,11 +23,11 @@ "qs": "^6.14.0", "react": "^19.1.0", "react-dom": "^19.1.0", - "react-redux": "^9.2.0", "react-router-dom": "^6.27.0", "sass": "^1.89.2", "typescript": "^5.8.2", - "universal-cookie": "^7.2.2" + "universal-cookie": "^7.2.2", + "zustand": "^5.0.5" }, "browserslist": { "production": [ diff --git a/src/App.tsx b/src/App.tsx index e970db6..bc42ee3 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -7,21 +7,17 @@ import Player from './components/Player/Player'; import SideBar from './components/SideBar/SideBar'; import PlaylistDetail from './pages/PlaylistDetail/PlaylistDetail'; import Playlists from './pages/Playlists/Playlists'; -import { useAppDispatch, useAppSelector } from './store/hooks'; -import { - fetchUserPlaylists, - selectUserPlaylists, -} from './store/reducers/userPlaylists.slice'; +import { useAppStore, useUserPlaylists } from './store/zustand-store'; const App = () => { const cookies = new Cookies(); - const dispatch = useAppDispatch(); + const fetchUserPlaylists = useAppStore((state) => state.fetchUserPlaylists); const user = 'smedjan'; - const { playlists, loading, error } = useAppSelector(selectUserPlaylists); + const { playlists, loading, error } = useUserPlaylists(); useEffect(() => { - dispatch(fetchUserPlaylists(user)); - }, [dispatch]); + fetchUserPlaylists(user); + }, [fetchUserPlaylists]); return ( <> diff --git a/src/components/Player/Player.spec.tsx b/src/components/Player/Player.spec.tsx index 682deba..ed468a0 100644 --- a/src/components/Player/Player.spec.tsx +++ b/src/components/Player/Player.spec.tsx @@ -1,34 +1,50 @@ import { render, screen } from '@testing-library/react'; -import { Provider } from 'react-redux'; -import { describe, expect, test } from 'vitest'; +import { beforeEach, describe, expect, test, vi } from 'vitest'; import { mockTrack } from '../../../tests/mockData'; -import { createMockStore } from '../../store/store'; +import { createTestStore, setupTestStoreMocks } from '../../store/test-utils'; +import * as zustandStore from '../../store/zustand-store'; import Player from './Player'; -const playerStore = createMockStore({ - currentSong: { song: null, playing: false }, -}); - -const playerStoreWithSong = createMockStore({ - currentSong: { song: mockTrack, playing: true }, -}); +vi.mock('../../store/zustand-store'); describe('Player', () => { - test('should render nothing', async () => { - render( - - - , - ); + beforeEach(() => { + vi.clearAllMocks(); + }); + + test('should render nothing when no song is loaded', async () => { + const testStore = createTestStore({ + currentSong: { + song: null, + playing: false, + }, + }); + setupTestStoreMocks(testStore, zustandStore, vi); + render(); expect(screen.queryByTestId('audioEml')).toBeFalsy(); }); - test('should render audio element', async () => { - render( - - - , - ); + test('should render audio element when song is loaded and playing', async () => { + const testStore = createTestStore({ + currentSong: { + song: mockTrack, + playing: true, + }, + }); + setupTestStoreMocks(testStore, zustandStore, vi); + render(); + expect(screen.getByTestId('audioEml')).toBeTruthy(); + }); + + test('should render audio element when song is loaded but not playing', async () => { + const testStore = createTestStore({ + currentSong: { + song: mockTrack, + playing: false, + }, + }); + setupTestStoreMocks(testStore, zustandStore, vi); + render(); expect(screen.getByTestId('audioEml')).toBeTruthy(); }); }); diff --git a/src/components/Player/Player.tsx b/src/components/Player/Player.tsx index c554d00..6b2b290 100644 --- a/src/components/Player/Player.tsx +++ b/src/components/Player/Player.tsx @@ -4,19 +4,15 @@ import Pause from '../../assets/pause.svg?react'; import Play from '../../assets/play.svg?react'; import Volume from '../../assets/volume.svg?react'; import VolumeMuted from '../../assets/volumeMuted.svg?react'; -import { useAppDispatch, useAppSelector } from '../../store/hooks'; -import { - playPause, - selectCurrentSong, -} from '../../store/reducers/currentSong.slice'; +import { useAppStore, useCurrentSong } from '../../store/zustand-store'; import msToMinutesAndSeconds from '../../utils/msToMinutes'; import useBar from '../../utils/useBar'; import useStopwatch from '../../utils/useStopwatch'; import styles from './Player.module.scss'; const Player = () => { - const dispatch = useAppDispatch(); - const { song, playing } = useAppSelector(selectCurrentSong); + const playPause = useAppStore((state) => state.playPause); + const { song, playing } = useCurrentSong(); const audioEml = useRef>(null); const timeRef = useRef>(null); @@ -97,7 +93,7 @@ const Player = () => { > -
@@ -105,7 +101,7 @@ const Player = () => {
barCallBack(event, timeRef, setProgress)} - onKeyDown={() => dispatch(playPause())} + onKeyDown={() => playPause()} // biome-ignore lint/a11y/useSemanticElements: clickable div is fine role="button" tabIndex={0} diff --git a/src/index.tsx b/src/index.tsx index ad240fb..f9943ad 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -1,17 +1,13 @@ import React from 'react'; import { createRoot } from 'react-dom/client'; -import { Provider } from 'react-redux'; import App from './App'; import './index.scss'; -import { store } from './store/store'; const container = document.getElementById('root'); // biome-ignore lint/style/noNonNullAssertion: const root = createRoot(container!); root.render( - - - + , ); diff --git a/src/pages/PlaylistDetail/PlaylistDetail.spec.tsx b/src/pages/PlaylistDetail/PlaylistDetail.spec.tsx index e9a4708..3ed350d 100644 --- a/src/pages/PlaylistDetail/PlaylistDetail.spec.tsx +++ b/src/pages/PlaylistDetail/PlaylistDetail.spec.tsx @@ -1,54 +1,82 @@ import { cleanup, render, screen } from '@testing-library/react'; -import { Provider } from 'react-redux'; -import { afterEach, describe, expect, test } from 'vitest'; +import { MemoryRouter } from 'react-router-dom'; +import { afterEach, describe, expect, test, vi } from 'vitest'; import { mockPlaylistDetails } from '../../../tests/mockData'; -import { createMockStore } from '../../store/store'; +import { createTestStore, setupTestStoreMocks } from '../../store/test-utils'; +import * as zustandStore from '../../store/zustand-store'; import PlaylistDetail from './PlaylistDetail'; -const store = createMockStore({ - playlistDetail: { - playlist: mockPlaylistDetails, - loading: false, - error: '', - }, -}); - -const loadingStore = createMockStore({ - playlistDetail: { - playlist: null, - loading: true, - error: '', - }, -}); +vi.mock('../../store/zustand-store'); describe('Playlist details', () => { - afterEach(cleanup); + afterEach(() => { + cleanup(); + vi.clearAllMocks(); + }); + + test('should render loader when playlist is loading', async () => { + const testStore = createTestStore({ + playlistDetail: { + playlist: null, + loading: true, + error: '', + }, + currentSong: { + song: null, + playing: false, + }, + }); + setupTestStoreMocks(testStore, zustandStore, vi); - test('should render loader', async () => { render( - + - , + , ); expect(screen.getByRole('progressbar', { busy: true })).toBeTruthy(); }); - test('should render playlist details', async () => { + test('should render playlist details when loaded', async () => { + const testStore = createTestStore({ + playlistDetail: { + playlist: mockPlaylistDetails, + loading: false, + error: '', + }, + currentSong: { + song: null, + playing: false, + }, + }); + setupTestStoreMocks(testStore, zustandStore, vi); render( - + - , + , ); expect(screen.findByText('Hits du Moment')).toBeTruthy(); }); - // test('should have a background color', async () => { - // render( - // - // - // , - // ); - - // expect(screen.getByTestId('Background').style.backgroundColor).toBeTruthy(); - // }); + test('should render error state when there is an error', async () => { + const testStore = createTestStore({ + playlistDetail: { + playlist: null, + loading: false, + error: 'Failed to load playlist', + }, + currentSong: { + song: null, + playing: false, + }, + }); + setupTestStoreMocks(testStore, zustandStore, vi); + render( + + + , + ); + // The error handling would depend on how the component handles error states + // For now, we just verify the component renders without crashing + expect(screen.queryByRole('progressbar', { busy: true })).toBeFalsy(); + }); }); diff --git a/src/pages/PlaylistDetail/PlaylistDetail.tsx b/src/pages/PlaylistDetail/PlaylistDetail.tsx index d6cc00d..2b0a06e 100644 --- a/src/pages/PlaylistDetail/PlaylistDetail.tsx +++ b/src/pages/PlaylistDetail/PlaylistDetail.tsx @@ -4,12 +4,7 @@ import { useParams } from 'react-router-dom'; import Time from '../../assets/time.svg?react'; import Loader from '../../components/Loader/Loader'; import NotFound from '../../components/NotFound/NotFound'; -import { useAppDispatch, useAppSelector } from '../../store/hooks'; -import { selectCurrentSong } from '../../store/reducers/currentSong.slice'; -import { - fetchPlaylistById, - playlistDetailsSelector, -} from '../../store/reducers/playlistDetail.slice'; +import { useAppStore, useCurrentSong, usePlaylistDetail } from '../../store/zustand-store'; import msToMinutesAndSeconds from '../../utils/msToMinutes'; import styles from './PlaylistDetail.module.scss'; import SongItem from './SongItem/SongItem'; @@ -17,15 +12,15 @@ import SongItem from './SongItem/SongItem'; const PlaylistDetail = () => { const { id } = useParams<{ id: string }>(); const coverRef = useRef(null); - const dispatch = useAppDispatch(); - const { playlist, loading, error } = useAppSelector(playlistDetailsSelector); - const { song } = useAppSelector(selectCurrentSong); + const fetchPlaylistById = useAppStore((state) => state.fetchPlaylistById); + const { playlist, loading, error } = usePlaylistDetail(); + const { song } = useCurrentSong(); useEffect(() => { if (id != null) { - void dispatch(fetchPlaylistById(id)); + void fetchPlaylistById(id); } - }, [id, dispatch]); + }, [id, fetchPlaylistById]); useEffect(() => { if (coverRef.current != null) { diff --git a/src/pages/PlaylistDetail/SongItem/SongItem.tsx b/src/pages/PlaylistDetail/SongItem/SongItem.tsx index 79cc742..7e27589 100644 --- a/src/pages/PlaylistDetail/SongItem/SongItem.tsx +++ b/src/pages/PlaylistDetail/SongItem/SongItem.tsx @@ -1,6 +1,5 @@ import Play from '../../../assets/play.svg?react'; -import { useAppDispatch } from '../../../store/hooks'; -import { loadSong } from '../../../store/reducers/currentSong.slice'; +import { useAppStore } from '../../../store/zustand-store'; import type { Track } from '../../../types/track.interface'; import formatDate from '../../../utils/formatDate'; import msToMinutesAndSeconds from '../../../utils/msToMinutes'; @@ -13,12 +12,12 @@ interface SongItemPros { } const SongItem = ({ song, index, current }: SongItemPros) => { - const dispatch = useAppDispatch(); + const loadSong = useAppStore((state) => state.loadSong); const previewAvailable = song.track?.preview_url !== null; const handleSongClick = (): void => { if (previewAvailable) { - dispatch(loadSong(song)); + loadSong(song); } }; diff --git a/src/store/hooks.ts b/src/store/hooks.ts deleted file mode 100644 index f76bd0c..0000000 --- a/src/store/hooks.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { - type TypedUseSelectorHook, - useDispatch, - useSelector, -} from 'react-redux'; -import type { AppDispatch, RootState } from './store'; - -// Use throughout your app instead of plain `useDispatch` and `useSelector` -export const useAppDispatch = () => useDispatch(); -export const useAppSelector: TypedUseSelectorHook = useSelector; diff --git a/src/store/reducers/currentSong.slice.ts b/src/store/reducers/currentSong.slice.ts deleted file mode 100644 index 5c51468..0000000 --- a/src/store/reducers/currentSong.slice.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { type PayloadAction, createSlice } from '@reduxjs/toolkit'; -import type { Track } from '../../types/track.interface'; -import type { RootState } from '../store'; - -interface CurrentSongState { - playing: boolean; - song: Track | null; -} - -export const initialCurrentSongState: CurrentSongState = { - playing: false, - song: null, -}; - -const currentSongSlice = createSlice({ - name: 'currentSong', - initialState: initialCurrentSongState, - reducers: { - loadSong: (state, action: PayloadAction) => { - state.playing = true; - state.song = action.payload; - }, - playPause: (state) => { - state.playing = !state.playing; - }, - }, -}); - -export const { loadSong, playPause } = currentSongSlice.actions; - -export default currentSongSlice; - -export const selectCurrentSong = (state: RootState) => state.currentSong; diff --git a/src/store/reducers/currentSong.spec.ts b/src/store/reducers/currentSong.spec.ts index e3260ac..2fd25db 100644 --- a/src/store/reducers/currentSong.spec.ts +++ b/src/store/reducers/currentSong.spec.ts @@ -1,28 +1,64 @@ import { describe, expect, it } from 'vitest'; import { mockTrack } from '../../../tests/mockData'; -import currentSongSlice, { - initialCurrentSongState, - loadSong, - playPause, -} from './currentSong.slice'; +import { createTestStore } from '../test-utils'; -describe('currentSong', () => { - const initialState = currentSongSlice.reducer(undefined, { type: 'unknown' }); - - it('should return the initial state', () => { - expect(initialState).toEqual(initialCurrentSongState); +describe('currentSong store', () => { + it('should have the initial state', () => { + const testStore = createTestStore(); + const state = testStore.getState(); + expect(state.currentSong.playing).toBe(false); + expect(state.currentSong.song).toBe(null); }); it('should handle loadSong', () => { - const newState = currentSongSlice.reducer(undefined, loadSong(mockTrack)); + const testStore = createTestStore(); + const { loadSong } = testStore.getState(); + + loadSong(mockTrack); + + const newState = testStore.getState().currentSong; expect(newState.playing).toBe(true); expect(newState.song).toEqual(mockTrack); }); + it('should handle playPause', () => { - const newState = currentSongSlice.reducer( - { playing: true, song: null }, - playPause(), - ); - expect(newState.playing).toBe(false); + const testStore = createTestStore(); + const { playPause, loadSong } = testStore.getState(); + + // First load a song to set playing to true + loadSong(mockTrack); + expect(testStore.getState().currentSong.playing).toBe(true); + + // Then toggle play/pause + playPause(); + expect(testStore.getState().currentSong.playing).toBe(false); + + // Toggle again + playPause(); + expect(testStore.getState().currentSong.playing).toBe(true); + }); + + it('should handle fetchUserPlaylists', async () => { + const testStore = createTestStore(); + const { fetchUserPlaylists } = testStore.getState(); + + await fetchUserPlaylists('testUser'); + + const state = testStore.getState().userPlaylists; + expect(state.loading).toBe(false); + expect(state.playlists).toEqual([]); + expect(state.error).toBe(''); + }); + + it('should handle fetchPlaylistById', async () => { + const testStore = createTestStore(); + const { fetchPlaylistById } = testStore.getState(); + + await fetchPlaylistById('test-id'); + + const state = testStore.getState().playlistDetail; + expect(state.loading).toBe(false); + expect(state.playlist).toBe(null); + expect(state.error).toBe(''); }); }); diff --git a/src/store/reducers/playlistDetail.slice.ts b/src/store/reducers/playlistDetail.slice.ts deleted file mode 100644 index 9dc2a45..0000000 --- a/src/store/reducers/playlistDetail.slice.ts +++ /dev/null @@ -1,55 +0,0 @@ -import { createAsyncThunk, createSlice } from '@reduxjs/toolkit'; -import { GetPlaylistDetail } from '../../API'; -import type { PlaylistType } from '../../types/playlist.interface'; -import type { RootState } from '../store'; - -export const fetchPlaylistById = createAsyncThunk( - 'playlists/fetchById', - async (playlistId: string, thunkAPI) => { - try { - return await GetPlaylistDetail(playlistId); - } catch (error) { - console.error(error); - return thunkAPI.rejectWithValue('something went wrong'); - } - }, -); - -interface PlaylistDetailState { - error: string; - loading: boolean; - playlist: null | PlaylistType; -} - -export const initialPlaylistDetailsState: PlaylistDetailState = { - error: '', - loading: true, - playlist: null, -}; - -const playlistDetailSlice = createSlice({ - name: 'playlistDetail', - initialState: initialPlaylistDetailsState, - reducers: {}, - extraReducers: (builder) => { - builder.addCase(fetchPlaylistById.pending, (state) => { - state.loading = true; - state.error = ''; - }); - builder.addCase(fetchPlaylistById.fulfilled, (state, action) => { - state.playlist = action.payload; - state.loading = false; - state.error = ''; - }); - builder.addCase(fetchPlaylistById.rejected, (state, action) => { - console.log(action); - state.loading = false; - state.error = 'an error has occurred'; - }); - }, -}); - -export default playlistDetailSlice; - -export const playlistDetailsSelector = (state: RootState) => - state.playlistDetail; diff --git a/src/store/reducers/userPlaylists.slice.ts b/src/store/reducers/userPlaylists.slice.ts deleted file mode 100644 index 8e05d29..0000000 --- a/src/store/reducers/userPlaylists.slice.ts +++ /dev/null @@ -1,54 +0,0 @@ -import { createAsyncThunk, createSlice } from '@reduxjs/toolkit'; -import { GetUserPlaylists } from '../../API'; -import type { PlaylistTrackDetails } from '../../types/playlists.interface'; -import type { RootState } from '../store'; - -export const fetchUserPlaylists = createAsyncThunk( - 'playlists/fetchUserPlaylists', - async (user: string, thunkAPI) => { - try { - return await GetUserPlaylists(user); - } catch (error) { - console.error(error); - return thunkAPI.rejectWithValue('something went wrong'); - } - }, -); - -interface PlaylistState { - error: string; - loading: boolean; - playlists: PlaylistTrackDetails[]; -} - -export const initialUserPlaylistState: PlaylistState = { - error: '', - loading: true, - playlists: [], -}; - -const userPlaylistSlice = createSlice({ - name: 'userPlaylists', - initialState: initialUserPlaylistState, - reducers: {}, - extraReducers: (builder) => { - builder.addCase(fetchUserPlaylists.pending, (state) => { - state.loading = true; - state.error = ''; - }); - builder.addCase(fetchUserPlaylists.fulfilled, (state, action) => { - state.playlists = action.payload.items; - state.loading = false; - state.error = ''; - }); - builder.addCase(fetchUserPlaylists.rejected, (state, action) => { - console.log(action); - state.loading = false; - state.error = 'an error has occurred'; - }); - }, -}); - -export default userPlaylistSlice; - -export const selectUserPlaylists = (state: RootState) => state.userPlaylists; diff --git a/src/store/store.ts b/src/store/store.ts deleted file mode 100644 index 5c546cc..0000000 --- a/src/store/store.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { configureStore } from '@reduxjs/toolkit'; -import currentSongSlice, { - initialCurrentSongState, -} from './reducers/currentSong.slice'; -import playlistDetailSlice, { - initialPlaylistDetailsState, -} from './reducers/playlistDetail.slice'; -import userPlaylistSlice, { - initialUserPlaylistState, -} from './reducers/userPlaylists.slice'; - -export const store = configureStore({ - reducer: { - currentSong: currentSongSlice.reducer, - userPlaylists: userPlaylistSlice.reducer, - playlistDetail: playlistDetailSlice.reducer, - }, -}); - -export const createMockStore = (preloadedState: Partial) => - configureStore({ - reducer: { - currentSong: currentSongSlice.reducer, - userPlaylists: userPlaylistSlice.reducer, - playlistDetail: playlistDetailSlice.reducer, - }, - preloadedState: { - currentSong: preloadedState.currentSong || initialCurrentSongState, - userPlaylists: preloadedState.userPlaylists || initialUserPlaylistState, - playlistDetail: - preloadedState.playlistDetail || initialPlaylistDetailsState, - }, - }); - -// Infer the `RootState` and `AppDispatch` types from the store itself -export type RootState = ReturnType; -// Inferred type: {posts: PostsState, comments: CommentsState, users: UsersState} -export type AppDispatch = typeof store.dispatch; - -export const rootState = store.getState(); diff --git a/src/store/test-utils.ts b/src/store/test-utils.ts new file mode 100644 index 0000000..b985744 --- /dev/null +++ b/src/store/test-utils.ts @@ -0,0 +1,92 @@ +import { create } from 'zustand'; +import type { Track } from '../types/track.interface'; +import type { AppState } from './zustand-store'; + +// Test store factory for testing +export const createTestStore = (initialState?: Partial) => { + return create((set) => ({ + // Default initial state + currentSong: { + playing: false, + song: null, + }, + userPlaylists: { + error: '', + loading: false, + playlists: [], + }, + playlistDetail: { + error: '', + loading: false, + playlist: null, + }, + + // Override with provided initial state + ...initialState, + + // Actions that work with the test store + loadSong: (song: Track) => { + set((state: AppState) => ({ + currentSong: { + ...state.currentSong, + playing: true, + song, + }, + })); + }, + + playPause: () => { + set((state: AppState) => ({ + currentSong: { + ...state.currentSong, + playing: !state.currentSong.playing, + }, + })); + }, + + fetchUserPlaylists: async (_user: string) => { + set(() => ({ + userPlaylists: { + playlists: [], + loading: false, + error: '', + }, + })); + }, + + fetchPlaylistById: async (_playlistId: string) => { + set(() => ({ + playlistDetail: { + playlist: null, + loading: false, + error: '', + }, + })); + }, + })); +}; + +// Helper function to set up store mocks with a test store +export const setupTestStoreMocks = ( + testStore: ReturnType, + zustandStore: typeof import('./zustand-store'), + vi: typeof import('vitest').vi, +) => { + // Create hooks that use the test store + const useAppStore = (selector: (state: AppState) => T): T => { + const state = testStore.getState(); + return selector(state); + }; + + const useCurrentSong = () => testStore.getState().currentSong; + const useUserPlaylists = () => testStore.getState().userPlaylists; + const usePlaylistDetail = () => testStore.getState().playlistDetail; + + // Set up the mocks to use our test store hooks + vi.mocked(zustandStore.useAppStore).mockImplementation(useAppStore); + vi.mocked(zustandStore.useCurrentSong).mockImplementation(useCurrentSong); + vi.mocked(zustandStore.useUserPlaylists).mockImplementation(useUserPlaylists); + vi.mocked(zustandStore.usePlaylistDetail).mockImplementation( + usePlaylistDetail, + ); +}; diff --git a/src/store/zustand-store.ts b/src/store/zustand-store.ts new file mode 100644 index 0000000..64393a8 --- /dev/null +++ b/src/store/zustand-store.ts @@ -0,0 +1,151 @@ +import { create } from 'zustand'; +import { GetPlaylistDetail, GetUserPlaylists } from '../API'; +import type { PlaylistType } from '../types/playlist.interface'; +import type { PlaylistTrackDetails } from '../types/playlists.interface'; +import type { Track } from '../types/track.interface'; + +interface CurrentSongState { + playing: boolean; + song: Track | null; +} + +interface UserPlaylistsState { + error: string; + loading: boolean; + playlists: PlaylistTrackDetails[]; +} + +interface PlaylistDetailState { + error: string; + loading: boolean; + playlist: null | PlaylistType; +} + +export interface AppState { + currentSong: CurrentSongState; + userPlaylists: UserPlaylistsState; + playlistDetail: PlaylistDetailState; + + // Actions + loadSong: (song: Track) => void; + playPause: () => void; + + fetchUserPlaylists: (user: string) => Promise; + fetchPlaylistById: (playlistId: string) => Promise; +} + +// Initial states +const initialCurrentSongState: CurrentSongState = { + playing: false, + song: null, +}; + +const initialUserPlaylistState: UserPlaylistsState = { + error: '', + loading: true, + playlists: [], +}; + +const initialPlaylistDetailsState: PlaylistDetailState = { + error: '', + loading: true, + playlist: null, +}; + +export const useAppStore = create((set) => ({ + // Initial state + currentSong: initialCurrentSongState, + userPlaylists: initialUserPlaylistState, + playlistDetail: initialPlaylistDetailsState, + + // Current song actions + loadSong: (song: Track) => { + set((state) => ({ + currentSong: { + ...state.currentSong, + playing: true, + song, + }, + })); + }, + + playPause: () => { + set((state) => ({ + currentSong: { + ...state.currentSong, + playing: !state.currentSong.playing, + }, + })); + }, + + // User playlists actions + fetchUserPlaylists: async (user: string) => { + set((state) => ({ + userPlaylists: { + ...state.userPlaylists, + loading: true, + error: '', + }, + })); + + try { + const result = await GetUserPlaylists(user); + set(() => ({ + userPlaylists: { + playlists: result.items, + loading: false, + error: '', + }, + })); + } catch (error) { + console.error(error); + set((state) => ({ + userPlaylists: { + ...state.userPlaylists, + loading: false, + error: 'an error has occurred', + }, + })); + } + }, + + // Playlist detail actions + fetchPlaylistById: async (playlistId: string) => { + set((state) => ({ + playlistDetail: { + ...state.playlistDetail, + loading: true, + error: '', + }, + })); + + try { + const playlist = await GetPlaylistDetail(playlistId); + set(() => ({ + playlistDetail: { + playlist, + loading: false, + error: '', + }, + })); + } catch (error) { + console.error(error); + set((state) => ({ + playlistDetail: { + ...state.playlistDetail, + loading: false, + error: 'an error has occurred', + }, + })); + } + }, +})); + +// Selector hooks for backward compatibility and cleaner code +export const useCurrentSong = () => useAppStore((state) => state.currentSong); +export const useUserPlaylists = () => + useAppStore((state) => state.userPlaylists); +export const usePlaylistDetail = () => + useAppStore((state) => state.playlistDetail); + +export default useAppStore; diff --git a/yarn.lock b/yarn.lock index d00ffe3..5d89716 100644 --- a/yarn.lock +++ b/yarn.lock @@ -895,18 +895,6 @@ resolved "https://registry.yarnpkg.com/@pkgjs/parseargs/-/parseargs-0.11.0.tgz#a77ea742fab25775145434eb1d2328cf5013ac33" integrity sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg== -"@reduxjs/toolkit@^2.8.2": - version "2.8.2" - resolved "https://registry.yarnpkg.com/@reduxjs/toolkit/-/toolkit-2.8.2.tgz#f4e9f973c6fc930c1e0f3bf462cc95210c28f5f9" - integrity sha512-MYlOhQ0sLdw4ud48FoC5w0dH9VfWQjtCjreKwYTT3l+r427qYC5Y8PihNutepr8XrNaBUDQo9khWUwQxZaqt5A== - dependencies: - "@standard-schema/spec" "^1.0.0" - "@standard-schema/utils" "^0.3.0" - immer "^10.0.3" - redux "^5.0.1" - redux-thunk "^3.1.0" - reselect "^5.1.0" - "@remix-run/router@1.20.0": version "1.20.0" resolved "https://registry.yarnpkg.com/@remix-run/router/-/router-1.20.0.tgz#03554155b45d8b529adf635b2f6ad1165d70d8b4" @@ -1106,16 +1094,6 @@ resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.43.0.tgz#42a88207659e404e8ffa655cae763cbad94906ab" integrity sha512-SnGhLiE5rlK0ofq8kzuDkM0g7FN1s5VYY+YSMTibP7CqShxCQvqtNxTARS4xX4PFJfHjG0ZQYX9iGzI3FQh5Aw== -"@standard-schema/spec@^1.0.0": - version "1.0.0" - resolved "https://registry.yarnpkg.com/@standard-schema/spec/-/spec-1.0.0.tgz#f193b73dc316c4170f2e82a881da0f550d551b9c" - integrity sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA== - -"@standard-schema/utils@^0.3.0": - version "0.3.0" - resolved "https://registry.yarnpkg.com/@standard-schema/utils/-/utils-0.3.0.tgz#3d5e608f16c2390c10528e98e59aef6bf73cae7b" - integrity sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g== - "@svgr/babel-plugin-add-jsx-attribute@8.0.0": version "8.0.0" resolved "https://registry.yarnpkg.com/@svgr/babel-plugin-add-jsx-attribute/-/babel-plugin-add-jsx-attribute-8.0.0.tgz#4001f5d5dd87fa13303e36ee106e3ff3a7eb8b22" @@ -1312,11 +1290,6 @@ dependencies: csstype "^3.0.2" -"@types/use-sync-external-store@^0.0.6": - version "0.0.6" - resolved "https://registry.yarnpkg.com/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz#60be8d21baab8c305132eb9cb912ed497852aadc" - integrity sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg== - "@vitejs/plugin-react@^4.5.2": version "4.5.2" resolved "https://registry.yarnpkg.com/@vitejs/plugin-react/-/plugin-react-4.5.2.tgz#8b98a8fbcefff4aa4c946966fbec560dc66d2bd9" @@ -2215,11 +2188,6 @@ iconv-lite@0.6.3: dependencies: safer-buffer ">= 2.1.2 < 3.0.0" -immer@^10.0.3: - version "10.1.1" - resolved "https://registry.yarnpkg.com/immer/-/immer-10.1.1.tgz#206f344ea372d8ea176891545ee53ccc062db7bc" - integrity sha512-s2MPrmjovJcoMaHtx6K11Ra7oD05NT97w1IC5zpMkT6Atjr7H8LjaDd81iIxUYpMKSRRNMJE703M1Fhr/TctHw== - immutable@^5.0.2: version "5.0.3" resolved "https://registry.yarnpkg.com/immutable/-/immutable-5.0.3.tgz#aa037e2313ea7b5d400cd9298fa14e404c933db1" @@ -2712,14 +2680,6 @@ react-is@^17.0.1: resolved "https://registry.yarnpkg.com/react-is/-/react-is-17.0.2.tgz#e691d4a8e9c789365655539ab372762b0efb54f0" integrity sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w== -react-redux@^9.2.0: - version "9.2.0" - resolved "https://registry.yarnpkg.com/react-redux/-/react-redux-9.2.0.tgz#96c3ab23fb9a3af2cb4654be4b51c989e32366f5" - integrity sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g== - dependencies: - "@types/use-sync-external-store" "^0.0.6" - use-sync-external-store "^1.4.0" - react-refresh@^0.17.0: version "0.17.0" resolved "https://registry.yarnpkg.com/react-refresh/-/react-refresh-0.17.0.tgz#b7e579c3657f23d04eccbe4ad2e58a8ed51e7e53" @@ -2757,26 +2717,11 @@ readdirp@~3.6.0: dependencies: picomatch "^2.2.1" -redux-thunk@^3.1.0: - version "3.1.0" - resolved "https://registry.yarnpkg.com/redux-thunk/-/redux-thunk-3.1.0.tgz#94aa6e04977c30e14e892eae84978c1af6058ff3" - integrity sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw== - -redux@^5.0.1: - version "5.0.1" - resolved "https://registry.yarnpkg.com/redux/-/redux-5.0.1.tgz#97fa26881ce5746500125585d5642c77b6e9447b" - integrity sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w== - regenerator-runtime@^0.14.0: version "0.14.1" resolved "https://registry.yarnpkg.com/regenerator-runtime/-/regenerator-runtime-0.14.1.tgz#356ade10263f685dda125100cd862c1db895327f" integrity sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw== -reselect@^5.1.0: - version "5.1.0" - resolved "https://registry.yarnpkg.com/reselect/-/reselect-5.1.0.tgz#c479139ab9dd91be4d9c764a7f3868210ef8cd21" - integrity sha512-aw7jcGLDpSgNDyWBQLv2cedml85qd95/iszJjN988zX1t7AVRJi19d9kto5+W7oCfQ94gyo40dVbT6g2k4/kXg== - resolve-from@^4.0.0: version "4.0.0" resolved "https://registry.yarnpkg.com/resolve-from/-/resolve-from-4.0.0.tgz#4abcd852ad32dd7baabfe9b40e00a36db5f392e6" @@ -3194,11 +3139,6 @@ update-browserslist-db@^1.1.1: escalade "^3.2.0" picocolors "^1.1.0" -use-sync-external-store@^1.4.0: - version "1.4.0" - resolved "https://registry.yarnpkg.com/use-sync-external-store/-/use-sync-external-store-1.4.0.tgz#adbc795d8eeb47029963016cefdf89dc799fcebc" - integrity sha512-9WXSPC5fMv61vaupRkCKCxsPxBocVnwakBEkMIHHpkTTg6icbJtg6jzgtLDm4bl3cSHAca52rYWih0k4K3PfHw== - vite-node@3.2.3: version "3.2.3" resolved "https://registry.yarnpkg.com/vite-node/-/vite-node-3.2.3.tgz#1c5a2282fe100114c26fd221daf506e69d392a36" @@ -3438,3 +3378,8 @@ yallist@^3.0.2: version "3.1.1" resolved "https://registry.yarnpkg.com/yallist/-/yallist-3.1.1.tgz#dbb7daf9bfd8bac9ab45ebf602b8cbad0d5d08fd" integrity sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g== + +zustand@^5.0.5: + version "5.0.5" + resolved "https://registry.yarnpkg.com/zustand/-/zustand-5.0.5.tgz#3e236f6a953142d975336d179bc735d97db17e84" + integrity sha512-mILtRfKW9xM47hqxGIxCv12gXusoY/xTSHBYApXozR0HmQv299whhBeeAcRy+KrPPybzosvJBCOmVjq6x12fCg== From 9268dd92a6fe64b9354c1ddc81f7eca91c8fcea8 Mon Sep 17 00:00:00 2001 From: Bastien DUMONT Date: Sun, 22 Jun 2025 16:08:51 +0200 Subject: [PATCH 2/3] format --- src/pages/PlaylistDetail/PlaylistDetail.tsx | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/pages/PlaylistDetail/PlaylistDetail.tsx b/src/pages/PlaylistDetail/PlaylistDetail.tsx index 2b0a06e..15b4bd5 100644 --- a/src/pages/PlaylistDetail/PlaylistDetail.tsx +++ b/src/pages/PlaylistDetail/PlaylistDetail.tsx @@ -4,7 +4,11 @@ import { useParams } from 'react-router-dom'; import Time from '../../assets/time.svg?react'; import Loader from '../../components/Loader/Loader'; import NotFound from '../../components/NotFound/NotFound'; -import { useAppStore, useCurrentSong, usePlaylistDetail } from '../../store/zustand-store'; +import { + useAppStore, + useCurrentSong, + usePlaylistDetail, +} from '../../store/zustand-store'; import msToMinutesAndSeconds from '../../utils/msToMinutes'; import styles from './PlaylistDetail.module.scss'; import SongItem from './SongItem/SongItem'; From 39534f738f0fcade110bc249f2997181c6c679d9 Mon Sep 17 00:00:00 2001 From: Bastien Dumont <32489032+bastiendmt@users.noreply.github.com> Date: Sun, 22 Jun 2025 16:09:42 +0200 Subject: [PATCH 3/3] Update src/pages/PlaylistDetail/PlaylistDetail.spec.tsx Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- src/pages/PlaylistDetail/PlaylistDetail.spec.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/pages/PlaylistDetail/PlaylistDetail.spec.tsx b/src/pages/PlaylistDetail/PlaylistDetail.spec.tsx index 3ed350d..9737977 100644 --- a/src/pages/PlaylistDetail/PlaylistDetail.spec.tsx +++ b/src/pages/PlaylistDetail/PlaylistDetail.spec.tsx @@ -54,7 +54,7 @@ describe('Playlist details', () => { , ); - expect(screen.findByText('Hits du Moment')).toBeTruthy(); + expect(await screen.findByText('Hits du Moment')).toBeTruthy(); }); test('should render error state when there is an error', async () => {