diff --git a/src/modules/utils/testUtils.ts b/src/modules/utils/testUtils.ts index 0dbc50d1d..ec740ded7 100644 --- a/src/modules/utils/testUtils.ts +++ b/src/modules/utils/testUtils.ts @@ -1,4 +1,5 @@ -import { FrequencyRecord, Note, NoteFrequencyRecord, PlayerNote, Section, Song } from 'interfaces'; +import { FrequencyRecord, Note, NoteFrequencyRecord, PlayerNote, Section, Song, SongPreview } from 'interfaces'; +import { getSongPreview } from 'modules/Songs/utils'; import range from 'modules/utils/range'; export const generateNote = (start: number, length = 1, data: Partial = {}): Note => ({ @@ -33,6 +34,7 @@ export const generateSong = (tracks: Section[][], data: Partial = {}): Son artist: 'artistTest', title: 'titleTest', video: 'videoTest', + language: ['english'], gap: 0, bpm: 60, // makes it easy to calc - beatLength = 1ms bar: 1000, // makes it easy to calc - beatLength = 1ms @@ -41,6 +43,9 @@ export const generateSong = (tracks: Section[][], data: Partial = {}): Son ...data, }) as any as Song; +export const generateSongPreview = (tracks: Section[][], data: Partial = {}): SongPreview => + getSongPreview(generateSong(tracks, data)); + export const generateSection = (start: number, length: number, notesCount: number): Section => ({ type: 'notes', start, diff --git a/src/routes/SingASong/SongSelection/Components/AdditionalListControls.tsx b/src/routes/SingASong/SongSelection/Components/AdditionalListControls.tsx index 13df3a61b..01d359809 100644 --- a/src/routes/SingASong/SongSelection/Components/AdditionalListControls.tsx +++ b/src/routes/SingASong/SongSelection/Components/AdditionalListControls.tsx @@ -5,7 +5,8 @@ import { Tooltip } from 'modules/Elements/Tooltip'; import styles from 'modules/GameEngine/Drawing/styles'; import { Dispatch, SetStateAction, useState } from 'react'; import QuickSearch from 'routes/SingASong/SongSelection/Components/QuickSearch'; -import { AppliedFilters } from 'routes/SingASong/SongSelection/Hooks/useSongList'; + +import { AppliedFilters } from 'routes/SingASong/SongSelection/Hooks/useSongListFilter'; interface Props { onRandom: () => void; diff --git a/src/routes/SingASong/SongSelection/Components/QuickSearch.tsx b/src/routes/SingASong/SongSelection/Components/QuickSearch.tsx index 46bd8d9b5..93ba0c641 100644 --- a/src/routes/SingASong/SongSelection/Components/QuickSearch.tsx +++ b/src/routes/SingASong/SongSelection/Components/QuickSearch.tsx @@ -5,7 +5,8 @@ import { useEventEffect } from 'modules/GameEvents/hooks'; import { REGULAR_ALPHA_CHARS } from 'modules/hooks/useKeyboard'; import { Dispatch, SetStateAction, useCallback, useEffect, useRef, useState } from 'react'; import { useHotkeys } from 'react-hotkeys-hook'; -import { AppliedFilters } from 'routes/SingASong/SongSelection/Hooks/useSongList'; + +import { AppliedFilters } from 'routes/SingASong/SongSelection/Hooks/useSongListFilter'; interface Props { setFilters: Dispatch>; diff --git a/src/routes/SingASong/SongSelection/Components/SongCard/TopContainer.tsx b/src/routes/SingASong/SongSelection/Components/SongCard/TopContainer.tsx index 2f328c5eb..3589ae1a2 100644 --- a/src/routes/SingASong/SongSelection/Components/SongCard/TopContainer.tsx +++ b/src/routes/SingASong/SongSelection/Components/SongCard/TopContainer.tsx @@ -8,7 +8,8 @@ import { useSongStats } from 'modules/Songs/stats/hooks'; import { isEurovisionSong } from 'modules/Songs/utils/specialSongsThemeChecks'; import { ReactNode, useMemo } from 'react'; import eurovisionIcon from 'routes/SingASong/SongSelection/Components/SongCard/eurovision-icon.svg'; -import { filteringFunctions } from 'routes/SingASong/SongSelection/Hooks/useSongList'; + +import { filteringFunctions } from 'routes/SingASong/SongSelection/Hooks/useSongListFilter'; export const TopContainer = (props: { song: SongPreview; isPopular: boolean; video?: ReactNode }) => { return ( diff --git a/src/routes/SingASong/SongSelection/Hooks/usePlaylists.tsx b/src/routes/SingASong/SongSelection/Hooks/usePlaylists.tsx index a32118908..74abe1c2d 100644 --- a/src/routes/SingASong/SongSelection/Hooks/usePlaylists.tsx +++ b/src/routes/SingASong/SongSelection/Hooks/usePlaylists.tsx @@ -5,7 +5,8 @@ import { ClosableTooltip } from 'modules/Elements/Tooltip'; // import eurovisionIcon from 'routes/SingASong/SongSelection/Components/SongCard/eurovision-icon.svg'; import { ReactElement, ReactNode, useMemo } from 'react'; import { useLanguageList } from 'routes/ExcludeLanguages/ExcludeLanguagesView'; -import { AppliedFilters, SongGroup } from 'routes/SingASong/SongSelection/Hooks/useSongList'; +import { SongGroup } from 'routes/SingASong/SongSelection/Hooks/useSongList'; +import { AppliedFilters } from 'routes/SingASong/SongSelection/Hooks/useSongListFilter'; export interface PlaylistEntry { name: string; diff --git a/src/routes/SingASong/SongSelection/Hooks/useRecommendedSongs.ts b/src/routes/SingASong/SongSelection/Hooks/useRecommendedSongs.ts index 20a9b14fd..d79bc0a1b 100644 --- a/src/routes/SingASong/SongSelection/Hooks/useRecommendedSongs.ts +++ b/src/routes/SingASong/SongSelection/Hooks/useRecommendedSongs.ts @@ -3,7 +3,8 @@ import { getAllStats } from 'modules/Songs/stats/common'; import { useMemo } from 'react'; import { useAsync } from 'react-use'; import { ExcludedLanguagesSetting, useSettingValue } from 'routes/Settings/SettingsState'; -import { filteringFunctions } from 'routes/SingASong/SongSelection/Hooks/useSongList'; + +import { filteringFunctions } from 'routes/SingASong/SongSelection/Hooks/useSongListFilter'; const POPULAR_SONGS_MIN_COUNT = 250; const POPULAR_SONGS_MAX_COUNT = 750; diff --git a/src/routes/SingASong/SongSelection/Hooks/useSongList.ts b/src/routes/SingASong/SongSelection/Hooks/useSongList.ts index 34995e15c..cd8e305f9 100644 --- a/src/routes/SingASong/SongSelection/Hooks/useSongList.ts +++ b/src/routes/SingASong/SongSelection/Hooks/useSongList.ts @@ -1,13 +1,9 @@ -import uFuzzy from '@leeoniya/ufuzzy'; import { captureException } from '@sentry/react'; -import dayjs from 'dayjs'; import { SongPreview } from 'interfaces'; import useSongIndex from 'modules/Songs/hooks/useSongIndex'; -import clearString from 'modules/utils/clearString'; -import { ReactNode, useDeferredValue, useEffect, useMemo, useState } from 'react'; -import { ExcludedLanguagesSetting, useSettingValue } from 'routes/Settings/SettingsState'; -import { usePlaylists } from 'routes/SingASong/SongSelection/Hooks/usePlaylists'; +import { ReactNode, useMemo } from 'react'; import useRecommendedSongs from 'routes/SingASong/SongSelection/Hooks/useRecommendedSongs'; +import { useSongListFilter } from 'routes/SingASong/SongSelection/Hooks/useSongListFilter'; export interface SongGroup { name: string; @@ -17,24 +13,6 @@ export interface SongGroup { isNew?: boolean; } -export interface AppliedFilters { - yearBefore?: number; - yearAfter?: number; - language?: string; - excludeLanguages?: string[]; - search?: string; - edition?: string; - recentlyUpdated?: boolean | null; - duet?: boolean | null; - specificSongs?: string[]; -} - -type FilterFunc = (songList: SongPreview[], ...args: any) => SongPreview[]; - -const isSearchApplied = (appliedFilters: AppliedFilters) => clearString(appliedFilters?.search ?? '').length > 2; - -const emptyFilters: AppliedFilters = {}; - const groupSongsByLetter = (song: SongPreview): Pick => { const nonAlphaRegex = /[^a-zA-Z]/; @@ -43,112 +21,6 @@ const groupSongsByLetter = (song: SongPreview): Pick => { }; }; -export const filteringFunctions: Record = { - language: (songList, language: string) => { - if (language === '') return songList; - - return songList.filter((song) => { - return song.language.includes(language); - }); - }, - excludeLanguages: (songList, languages: string[] = [], appliedFilters: AppliedFilters) => { - if (languages.length === 0 || isSearchApplied(appliedFilters) || appliedFilters.edition === 'esc') return songList; - - return songList.filter((song) => { - return !song.language.every((songLang) => languages.includes(songLang!)); - }); - }, - search: (songList, search: string) => { - const cleanSearch = clearString(search); - - if (cleanSearch.length > 0) { - const fuzz = new uFuzzy({}); - const [, info, order] = fuzz.search( - songList.map((song) => `${clearString(song.artist)} ${clearString(song.title)}`), - search, - ); - if (order && info) { - return order.map((item) => songList[info.idx[item]]); - } - } - - return cleanSearch.length > 0 ? songList.filter((song) => song.search.includes(cleanSearch)) : songList; - }, - duet: (songList, duet: boolean | null) => { - if (duet === null) return songList; - - return songList.filter((song) => (duet ? song.tracksCount > 1 : song.tracksCount === 1)); - }, - yearBefore: (songList, yearBefore: number) => { - if (!yearBefore) return songList; - - return songList.filter((song) => Number(song.year) < yearBefore); - }, - yearAfter: (songList, yearAfter: number) => { - if (!yearAfter) return songList; - - return songList.filter((song) => Number(song.year) >= yearAfter); - }, - edition: (songList, edition: string) => { - const cleanEdition = clearString(edition); - - return cleanEdition.length - ? songList.filter((song) => clearString(song.edition ?? '').includes(edition)) - : songList; - }, - recentlyUpdated: (songList) => { - const after = dayjs().subtract(31, 'days'); - - return songList.filter((song) => song.lastUpdate && dayjs(song.lastUpdate).isAfter(after)); - }, - specificSongs: (songList, specificSongs: string[], appliedFilters: AppliedFilters) => { - if (isSearchApplied(appliedFilters)) return songList; - - return songList.filter((song) => specificSongs.includes(song.id)); - }, -}; - -const applyFilters = (list: SongPreview[], appliedFilters: AppliedFilters): SongPreview[] => { - return Object.entries(appliedFilters) - .filter((filters): filters is [keyof AppliedFilters, FilterFunc] => filters[0] in filteringFunctions) - .reduce((songList, [name, value]) => filteringFunctions[name](songList, value, appliedFilters, list), list); -}; - -export const useSongListFilter = (list: SongPreview[], popular: string[], isLoading: boolean) => { - const [excludedLanguages] = useSettingValue(ExcludedLanguagesSetting); - const prefilteredList = useMemo( - () => applyFilters(list, { excludeLanguages: excludedLanguages ?? [] }), - [list, excludedLanguages], - ); - - const playlists = usePlaylists(prefilteredList, popular, isLoading); - - const [selectedPlaylist, setSelectedPlaylist] = useState( - new URLSearchParams(global.location?.search).get('playlist') ?? null, - ); - const [filters, setFilters] = useState(emptyFilters); - - useEffect(() => { - setFilters(emptyFilters); - }, [selectedPlaylist]); - - const deferredFilters = useDeferredValue(filters); - - const playlist = playlists.find((p) => p.name === selectedPlaylist) ?? playlists[0]; - - const filteredList = useMemo( - () => - applyFilters(list, { - ...(playlist?.filters ?? {}), - ...deferredFilters, - excludeLanguages: excludedLanguages ?? [], - }), - [list, deferredFilters, excludedLanguages, playlist], - ); - - return { filters, filteredList, setFilters, selectedPlaylist, setSelectedPlaylist, playlists, playlist }; -}; - export default function useSongList() { const songList = useSongIndex(); const { diff --git a/src/routes/SingASong/SongSelection/Hooks/useSongListFilter.test.ts b/src/routes/SingASong/SongSelection/Hooks/useSongListFilter.test.ts new file mode 100644 index 000000000..7f80335ca --- /dev/null +++ b/src/routes/SingASong/SongSelection/Hooks/useSongListFilter.test.ts @@ -0,0 +1,35 @@ +import { act, renderHook } from '@testing-library/react'; +import { SongPreview } from 'interfaces'; +import { generateSongPreview } from 'modules/utils/testUtils'; +import { useSongListFilter } from 'routes/SingASong/SongSelection/Hooks/useSongListFilter'; +import { beforeEach } from 'vitest'; + +const list: SongPreview[] = [ + generateSongPreview([], { artist: 'diacritics characters', title: 'konik na biegunach' }), + generateSongPreview([], { artist: 'kombi', title: 'pokolenie' }), +]; + +describe('useSongListFilter', () => { + beforeEach(() => { + global.location.search = '?playlist=All'; + }); + it('should return filtered list', () => { + const { result } = renderHook(() => useSongListFilter(list, [], false)); + + act(() => { + result.current.setFilters({ search: 'koń' }); + }); + + expect(result.current.filteredList[0]).toEqual(list[0]); + }); + + it('should return all songs when search is empty', () => { + const { result } = renderHook(() => useSongListFilter(list, [], false)); + + act(() => { + result.current.setFilters({ search: ' ' }); + }); + + expect(result.current.filteredList).toEqual(list); + }); +}); diff --git a/src/routes/SingASong/SongSelection/Hooks/useSongListFilter.ts b/src/routes/SingASong/SongSelection/Hooks/useSongListFilter.ts new file mode 100644 index 000000000..3df02cf3b --- /dev/null +++ b/src/routes/SingASong/SongSelection/Hooks/useSongListFilter.ts @@ -0,0 +1,131 @@ +import uFuzzy from '@leeoniya/ufuzzy'; +import dayjs from 'dayjs'; +import { SongPreview } from 'interfaces'; +import clearString, { removeAccents } from 'modules/utils/clearString'; +import { useDeferredValue, useEffect, useMemo, useState } from 'react'; +import { ExcludedLanguagesSetting, useSettingValue } from 'routes/Settings/SettingsState'; +import { usePlaylists } from 'routes/SingASong/SongSelection/Hooks/usePlaylists'; + +type FilterFunc = (songList: SongPreview[], ...args: any) => SongPreview[]; + +export interface AppliedFilters { + yearBefore?: number; + yearAfter?: number; + language?: string; + excludeLanguages?: string[]; + search?: string; + edition?: string; + recentlyUpdated?: boolean | null; + duet?: boolean | null; + specificSongs?: string[]; +} + +const isSearchApplied = (appliedFilters: AppliedFilters) => clearString(appliedFilters?.search ?? '').length > 2; + +const emptyFilters: AppliedFilters = {}; + +export const filteringFunctions: Record = { + language: (songList, language: string) => { + if (language === '') return songList; + + return songList.filter((song) => { + return song.language.includes(language); + }); + }, + excludeLanguages: (songList, languages: string[] = [], appliedFilters: AppliedFilters) => { + if (languages.length === 0 || isSearchApplied(appliedFilters) || appliedFilters.edition === 'esc') return songList; + + return songList.filter((song) => { + return !song.language.every((songLang) => languages.includes(songLang!)); + }); + }, + search: (songList, search: string) => { + const cleanSearch = clearString(search); + + if (cleanSearch.length > 0) { + const fuzz = new uFuzzy({}); + const [, info, order] = fuzz.search( + songList.map((song) => `${removeAccents(song.artist)} ${removeAccents(song.title)}`), + removeAccents(search), + ); + if (order && info) { + return order.map((item) => songList[info.idx[item]]); + } else { + return songList.filter((song) => song.search.includes(cleanSearch)); + } + } + + return songList; + }, + duet: (songList, duet: boolean | null) => { + if (duet === null) return songList; + + return songList.filter((song) => (duet ? song.tracksCount > 1 : song.tracksCount === 1)); + }, + yearBefore: (songList, yearBefore: number) => { + if (!yearBefore) return songList; + + return songList.filter((song) => Number(song.year) < yearBefore); + }, + yearAfter: (songList, yearAfter: number) => { + if (!yearAfter) return songList; + + return songList.filter((song) => Number(song.year) >= yearAfter); + }, + edition: (songList, edition: string) => { + const cleanEdition = clearString(edition); + + return cleanEdition.length + ? songList.filter((song) => clearString(song.edition ?? '').includes(edition)) + : songList; + }, + recentlyUpdated: (songList) => { + const after = dayjs().subtract(31, 'days'); + + return songList.filter((song) => song.lastUpdate && dayjs(song.lastUpdate).isAfter(after)); + }, + specificSongs: (songList, specificSongs: string[], appliedFilters: AppliedFilters) => { + if (isSearchApplied(appliedFilters)) return songList; + + return songList.filter((song) => specificSongs.includes(song.id)); + }, +}; +const applyFilters = (list: SongPreview[], appliedFilters: AppliedFilters): SongPreview[] => { + return Object.entries(appliedFilters) + .filter((filters): filters is [keyof AppliedFilters, FilterFunc] => filters[0] in filteringFunctions) + .reduce((songList, [name, value]) => filteringFunctions[name](songList, value, appliedFilters, list), list); +}; + +export const useSongListFilter = (list: SongPreview[], popular: string[], isLoading: boolean) => { + const [excludedLanguages] = useSettingValue(ExcludedLanguagesSetting); + const prefilteredList = useMemo( + () => applyFilters(list, { excludeLanguages: excludedLanguages ?? [] }), + [list, excludedLanguages], + ); + + const playlists = usePlaylists(prefilteredList, popular, isLoading); + + const [selectedPlaylist, setSelectedPlaylist] = useState( + new URLSearchParams(global.location?.search).get('playlist') ?? null, + ); + const playlist = playlists.find((p) => p.name === selectedPlaylist) ?? playlists[0]; + const [filters, setFilters] = useState(emptyFilters); + + useEffect(() => { + setFilters(emptyFilters); + }, [selectedPlaylist]); + + const deferredFilters = useDeferredValue(filters); + + const filteredList = useMemo( + () => + applyFilters(list, { + ...(playlist?.filters ?? {}), + ...deferredFilters, + excludeLanguages: excludedLanguages ?? [], + }), + [list, deferredFilters, excludedLanguages, playlist], + ); + + return { filters, filteredList, setFilters, selectedPlaylist, setSelectedPlaylist, playlists, playlist }; +}; diff --git a/src/routes/SingASong/SongSelection/Hooks/useSongSelectionKeyboardNavigation.ts b/src/routes/SingASong/SongSelection/Hooks/useSongSelectionKeyboardNavigation.ts index f93fa0c20..eeb113daf 100644 --- a/src/routes/SingASong/SongSelection/Hooks/useSongSelectionKeyboardNavigation.ts +++ b/src/routes/SingASong/SongSelection/Hooks/useSongSelectionKeyboardNavigation.ts @@ -8,7 +8,8 @@ import tuple from 'modules/utils/tuple'; import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'; import { HelpEntry } from 'routes/KeyboardHelp/Context'; import selectRandomSong from 'routes/SingASong/SongSelection/Hooks/selectRandomSong'; -import { AppliedFilters, SongGroup } from 'routes/SingASong/SongSelection/Hooks/useSongList'; +import { SongGroup } from 'routes/SingASong/SongSelection/Hooks/useSongList'; +import { AppliedFilters } from 'routes/SingASong/SongSelection/Hooks/useSongListFilter'; const useTwoDimensionalNavigation = (groups: SongGroup[] = [], itemsPerRow: number) => { const [cursorPosition, setCursorPosition] = useState<[number, number]>([0, 0]);