diff --git a/resources/assets/js/__tests__/UnitTestCase.ts b/resources/assets/js/__tests__/UnitTestCase.ts index fef84265..030bba8e 100644 --- a/resources/assets/js/__tests__/UnitTestCase.ts +++ b/resources/assets/js/__tests__/UnitTestCase.ts @@ -82,13 +82,16 @@ export default abstract class UnitTestCase { }) } - protected be (user?: User) { + protected auth (user?: User = null) { + return this.be(user) + } + + protected be (user?: User = null) { userStore.state.current = user || factory('user') return this } protected beAdmin () { - factory.states('admin')('user') return this.be(factory.states('admin')('user')) } diff --git a/resources/assets/js/__tests__/setup.ts b/resources/assets/js/__tests__/setup.ts index 48e403ec..fa1d5b20 100644 --- a/resources/assets/js/__tests__/setup.ts +++ b/resources/assets/js/__tests__/setup.ts @@ -54,4 +54,18 @@ window.SSO_PROVIDERS = [] window.createLemonSqueezy = vi.fn() +Object.defineProperty(window, 'matchMedia', { + writable: true, + value: vi.fn().mockImplementation(query => ({ + matches: true, + media: query, + onchange: null, + addListener: vi.fn(), + removeListener: vi.fn(), + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + dispatchEvent: vi.fn(), + })), +}) + Axios.defaults.adapter = vi.fn() diff --git a/resources/assets/js/components/screens/PodcastListScreen.vue b/resources/assets/js/components/screens/PodcastListScreen.vue index ca31744c..64f2983d 100644 --- a/resources/assets/js/components/screens/PodcastListScreen.vue +++ b/resources/assets/js/components/screens/PodcastListScreen.vue @@ -49,7 +49,7 @@ import { useFuzzySearch } from '@/composables/useFuzzySearch' import Btn from '@/components/ui/form/Btn.vue' import BtnGroup from '@/components/ui/form/BtnGroup.vue' -import ListFilter from '@/components/song/SongListFilter.vue' +import ListFilter from '@/components/song/song-list/SongListFilter.vue' import PodcastItem from '@/components/podcast/PodcastItem.vue' import PodcastItemSkeleton from '@/components/ui/skeletons/PodcastItemSkeleton.vue' import PodcastListSorter from '@/components/podcast/PodcastListSorter.vue' diff --git a/resources/assets/js/components/screens/PodcastScreen.vue b/resources/assets/js/components/screens/PodcastScreen.vue index 8ba36616..e69854e7 100644 --- a/resources/assets/js/components/screens/PodcastScreen.vue +++ b/resources/assets/js/components/screens/PodcastScreen.vue @@ -91,7 +91,7 @@ import ScreenHeaderSkeleton from '@/components/ui/skeletons/ScreenHeaderSkeleton import EpisodeItem from '@/components/podcast/EpisodeItem.vue' import VirtualScroller from '@/components/ui/VirtualScroller.vue' import Btn from '@/components/ui/form/Btn.vue' -import ListFilter from '@/components/song/SongListFilter.vue' +import ListFilter from '@/components/song/song-list/SongListFilter.vue' import BtnGroup from '@/components/ui/form/BtnGroup.vue' import EpisodeItemSkeleton from '@/components/ui/skeletons/EpisodeItemSkeleton.vue' diff --git a/resources/assets/js/components/song/__snapshots__/SongList.spec.ts.snap b/resources/assets/js/components/song/__snapshots__/SongList.spec.ts.snap deleted file mode 100644 index e9c27506..00000000 --- a/resources/assets/js/components/song/__snapshots__/SongList.spec.ts.snap +++ /dev/null @@ -1,9 +0,0 @@ -// Vitest Snapshot v1 - -exports[`renders 1`] = ` -
-
# Title
Album - Time
-

-
-`; diff --git a/resources/assets/js/components/song/__snapshots__/SongListItem.spec.ts.snap b/resources/assets/js/components/song/__snapshots__/SongListItem.spec.ts.snap deleted file mode 100644 index 77270888..00000000 --- a/resources/assets/js/components/song/__snapshots__/SongListItem.spec.ts.snap +++ /dev/null @@ -1,10 +0,0 @@ -// Vitest Snapshot v1 - -exports[`renders 1`] = ` -
- -
Test SongTest ArtistTest Album - 16:40 -
-
-`; diff --git a/resources/assets/js/components/song/song-list/SongList.spec.ts b/resources/assets/js/components/song/song-list/SongList.spec.ts new file mode 100644 index 00000000..678f94bd --- /dev/null +++ b/resources/assets/js/components/song/song-list/SongList.spec.ts @@ -0,0 +1,67 @@ +import { ref } from 'vue' +import { expect, it } from 'vitest' +import UnitTestCase from '@/__tests__/UnitTestCase' +import factory from '@/__tests__/factory' +import { arrayify } from '@/utils/helpers' +import { + PlayableListConfigKey, + PlayableListContextKey, + PlayableListSortFieldKey, + PlayablesKey, + SelectedPlayablesKey, + SongListSortOrderKey, +} from '@/symbols' +import SongList from './SongList.vue' + +let songs: Playable[] + +new class extends UnitTestCase { + protected test () { + it('renders', async () => { + const { html } = await this.renderComponent(factory('song', 5)) + expect(html()).toMatchSnapshot() + }) + } + + private async renderComponent ( + _songs: MaybeArray, + config: Partial = { + sortable: true, + reorderable: true, + }, + context: PlayableListContext = { + type: 'Album', + }, + selectedPlayables: Playable[] = [], + sortField: PlayableListSortField = 'title', + sortOrder: SortOrder = 'asc', + ) { + songs = arrayify(_songs) + + const sortFieldRef = ref(sortField) + const sortOrderRef = ref(sortOrder) + + await this.router.activateRoute({ + screen: 'Songs', + path: '/songs', + }) + + return this.render(SongList, { + global: { + stubs: { + VirtualScroller: this.stub('virtual-scroller'), + SongListSorter: this.stub('song-list-sorter'), + SongListHeader: this.stub('song-list-header'), + }, + provide: { + [PlayablesKey]: [ref(songs)], + [SelectedPlayablesKey]: [ref(selectedPlayables), (value: Playable[]) => (selectedPlayables = value)], + [PlayableListConfigKey]: [config], + [PlayableListContextKey]: [context], + [PlayableListSortFieldKey]: [sortFieldRef, (value: PlayableListSortField) => (sortFieldRef.value = value)], + [SongListSortOrderKey]: [sortOrderRef, (value: SortOrder) => (sortOrderRef.value = value)], + }, + }, + }) + } +} diff --git a/resources/assets/js/components/song/SongList.vue b/resources/assets/js/components/song/song-list/SongList.vue similarity index 74% rename from resources/assets/js/components/song/SongList.vue rename to resources/assets/js/components/song/song-list/SongList.vue index 84d6e976..5566f049 100644 --- a/resources/assets/js/components/song/SongList.vue +++ b/resources/assets/js/components/song/song-list/SongList.vue @@ -8,80 +8,7 @@ @keydown.enter.prevent.stop="handleEnter" @keydown.a.prevent="handleA" > -
- - # - - - - Title - - - - - - - - - - - - - - - Time - - - - - -
+ import { findIndex, findLastIndex, throttle } from 'lodash' import isMobile from 'ismobilejs' -import { faCaretDown, faCaretUp } from '@fortawesome/free-solid-svg-icons' import type { Ref } from 'vue' import { computed, nextTick, onMounted, ref, watch } from 'vue' import { eventBus } from '@/utils/eventBus' -import { arrayify, requireInjection } from '@/utils/helpers' +import { requireInjection } from '@/utils/helpers' import { getPlayableCollectionContentType } from '@/utils/typeGuards' import { preferenceStore as preferences } from '@/stores/preferenceStore' import { queueStore } from '@/stores/queueStore' @@ -127,12 +53,11 @@ import { PlayableListSortFieldKey, PlayablesKey, SelectedPlayablesKey, - SongListSortOrderKey, } from '@/symbols' -import SongListItem from '@/components/song/SongListItem.vue' -import SongListSorter from '@/components/song/SongListSorter.vue' +import SongListItem from '@/components/song/song-list/SongListItem.vue' import VirtualScroller from '@/components/ui/VirtualScroller.vue' +import SongListHeader from '@/components/song/song-list/SongListHeader.vue' const emit = defineEmits<{ (e: 'press:enter', event: KeyboardEvent): void @@ -148,8 +73,7 @@ const { getDroppedData, acceptsDrop } = useDroppable(['playables']) const [playables] = requireInjection<[Ref]>(PlayablesKey) const [selectedPlayables, setSelectedPlayables] = requireInjection<[Ref, Closure]>(SelectedPlayablesKey) -const [sortField, setSortField] = requireInjection<[Ref>, Closure]>(PlayableListSortFieldKey) -const [sortOrder, setSortOrder] = requireInjection<[Ref, Closure]>(SongListSortOrderKey) +const [sortField] = requireInjection<[Ref>, Closure]>(PlayableListSortFieldKey) const [config] = requireInjection<[Partial]>(PlayableListConfigKey, [{}]) const [context] = requireInjection<[PlayableListContext]>(PlayableListContextKey) @@ -166,11 +90,6 @@ const shouldTriggerContinuousPlayback = computed(() => { const contentType = computed(() => getPlayableCollectionContentType(rows.value.map(({ playable }) => playable))) -const sortingByAlbumOrPodcast = computed(() => { - const sortFields = arrayify(sortField.value) - return sortFields[0] === 'album_name' || sortFields[0] === 'podcast_title' -}) - const selectAllRows = () => rows.value.forEach(row => (row.selected = true)) const clearSelection = () => rows.value.forEach(row => (row.selected = false)) const handleA = (event: KeyboardEvent) => (event.ctrlKey || event.metaKey) && selectAllRows() @@ -212,16 +131,9 @@ const generateRows = () => { })) } -const sort = (field: MaybeArray) => { - // there are certain circumstances where sorting is simply disallowed, e.g. in Queue - if (!config.sortable) { - return - } - - setSortField(field) - setSortOrder(sortOrder.value === 'asc' ? 'desc' : 'asc') - - emit('sort', field, sortOrder.value) +const sort = (field: MaybeArray, order: SortOrder) => { + // we simply pass the sort event from the header up to the parent component + emit('sort', field, order) } const render = () => { @@ -418,9 +330,8 @@ const calculatedItemHeight = computed(() => { const totalAdditionalPixels = discCount * discNumberHeight const totalHeight = (rows.value.length * standardSongItemHeight) + totalAdditionalPixels - const averageHeight = totalHeight / rows.value.length - return averageHeight + return totalHeight / rows.value.length }) defineExpose({ @@ -449,7 +360,7 @@ onMounted(() => render()) } &.track-number { - @apply basis-16 pl-6; + @apply basis-16; } &.album { diff --git a/resources/assets/js/components/song/SongListControls.spec.ts b/resources/assets/js/components/song/song-list/SongListControls.spec.ts similarity index 100% rename from resources/assets/js/components/song/SongListControls.spec.ts rename to resources/assets/js/components/song/song-list/SongListControls.spec.ts diff --git a/resources/assets/js/components/song/SongListControls.vue b/resources/assets/js/components/song/song-list/SongListControls.vue similarity index 99% rename from resources/assets/js/components/song/SongListControls.vue rename to resources/assets/js/components/song/song-list/SongListControls.vue index 6b6d19ea..41abedb0 100644 --- a/resources/assets/js/components/song/SongListControls.vue +++ b/resources/assets/js/components/song/song-list/SongListControls.vue @@ -119,7 +119,7 @@ const emit = defineEmits<{ (e: 'clearQueue' | 'deletePlaylist' | 'refresh'): void }>() -const SongListFilter = defineAsyncComponent(() => import('@/components/song/SongListFilter.vue')) +const SongListFilter = defineAsyncComponent(() => import('@/components/song/song-list/SongListFilter.vue')) const config = toRef(props, 'config') diff --git a/resources/assets/js/components/song/SongListFilter.spec.ts b/resources/assets/js/components/song/song-list/SongListFilter.spec.ts similarity index 100% rename from resources/assets/js/components/song/SongListFilter.spec.ts rename to resources/assets/js/components/song/song-list/SongListFilter.spec.ts diff --git a/resources/assets/js/components/song/SongListFilter.vue b/resources/assets/js/components/song/song-list/SongListFilter.vue similarity index 100% rename from resources/assets/js/components/song/SongListFilter.vue rename to resources/assets/js/components/song/song-list/SongListFilter.vue diff --git a/resources/assets/js/components/song/SongList.spec.ts b/resources/assets/js/components/song/song-list/SongListHeader.spec.ts similarity index 79% rename from resources/assets/js/components/song/SongList.spec.ts rename to resources/assets/js/components/song/song-list/SongListHeader.spec.ts index 4f28474a..6b3e2afd 100644 --- a/resources/assets/js/components/song/SongList.spec.ts +++ b/resources/assets/js/components/song/song-list/SongListHeader.spec.ts @@ -2,24 +2,19 @@ import { screen } from '@testing-library/vue' import { ref } from 'vue' import { expect, it } from 'vitest' import UnitTestCase from '@/__tests__/UnitTestCase' -import factory from '@/__tests__/factory' -import { arrayify } from '@/utils/helpers' import { PlayableListConfigKey, PlayableListContextKey, PlayableListSortFieldKey, - PlayablesKey, SelectedPlayablesKey, SongListSortOrderKey, } from '@/symbols' -import SongList from './SongList.vue' - -let songs: Playable[] +import Component from './SongListHeader.vue' new class extends UnitTestCase { protected test () { it('renders', async () => { - const { html } = await this.renderComponent(factory('song', 5)) + const { html } = await this.renderComponent() expect(html()).toMatchSnapshot() }) @@ -29,7 +24,7 @@ new class extends UnitTestCase { ['album_name', 'header-album'], ['length', 'header-length'], ])('sorts by %s upon %s clicked', async (field, testId) => { - const { emitted } = await this.renderComponent(factory('song', 5)) + const { emitted } = await this.renderComponent() await this.user.click(screen.getByTestId(testId)) expect(emitted().sort[0]).toEqual([field, 'desc']) @@ -39,7 +34,7 @@ new class extends UnitTestCase { }) it('cannot be sorted if configured so', async () => { - const { emitted } = await this.renderComponent(factory('song', 5), { + const { emitted } = await this.renderComponent({ sortable: false, reorderable: true, }) @@ -50,7 +45,6 @@ new class extends UnitTestCase { } private async renderComponent ( - _songs: MaybeArray, config: Partial = { sortable: true, reorderable: true, @@ -62,8 +56,6 @@ new class extends UnitTestCase { sortField: PlayableListSortField = 'title', sortOrder: SortOrder = 'asc', ) { - songs = arrayify(_songs) - const sortFieldRef = ref(sortField) const sortOrderRef = ref(sortOrder) @@ -72,14 +64,15 @@ new class extends UnitTestCase { path: '/songs', }) - return this.render(SongList, { + return this.render(Component, { + props: { + contentType: 'songs', + }, global: { stubs: { - VirtualScroller: this.stub('virtual-scroller'), SongListSorter: this.stub('song-list-sorter'), }, provide: { - [PlayablesKey]: [ref(songs)], [SelectedPlayablesKey]: [ref(selectedPlayables), (value: Playable[]) => (selectedPlayables = value)], [PlayableListConfigKey]: [config], [PlayableListContextKey]: [context], diff --git a/resources/assets/js/components/song/song-list/SongListHeader.vue b/resources/assets/js/components/song/song-list/SongListHeader.vue new file mode 100644 index 00000000..51654b43 --- /dev/null +++ b/resources/assets/js/components/song/song-list/SongListHeader.vue @@ -0,0 +1,124 @@ + + + diff --git a/resources/assets/js/components/song/SongListItem.spec.ts b/resources/assets/js/components/song/song-list/SongListItem.spec.ts similarity index 100% rename from resources/assets/js/components/song/SongListItem.spec.ts rename to resources/assets/js/components/song/song-list/SongListItem.spec.ts diff --git a/resources/assets/js/components/song/SongListItem.vue b/resources/assets/js/components/song/song-list/SongListItem.vue similarity index 82% rename from resources/assets/js/components/song/SongListItem.vue rename to resources/assets/js/components/song/song-list/SongListItem.vue index 10fefa98..17ca7c72 100644 --- a/resources/assets/js/components/song/SongListItem.vue +++ b/resources/assets/js/components/song/song-list/SongListItem.vue @@ -2,23 +2,23 @@

Disc {{ item.playable.disc }}

- + @@ -35,14 +35,14 @@ {{ artist }} - {{ album }} + {{ album }} - {{ fmtLength }} + {{ fmtLength }} @@ -58,6 +58,7 @@ import { isSong } from '@/utils/typeGuards' import { secondsToHis } from '@/utils/formatters' import { useAuthorization } from '@/composables/useAuthorization' import { useKoelPlus } from '@/composables/useKoelPlus' +import { usePlayableListColumnVisibility } from '@/composables/usePlayableListColumnVisibility' import { PlayableListConfigKey } from '@/symbols' import LikeButton from '@/components/song/SongLikeButton.vue' @@ -76,6 +77,7 @@ const [config] = requireInjection<[Partial]>(PlayableListCon const { currentUser } = useAuthorization() const { isPlus } = useKoelPlus() +const { shouldShowColumn } = usePlayableListColumnVisibility() const { item } = toRefs(props) diff --git a/resources/assets/js/components/song/SongListSorter.spec.ts b/resources/assets/js/components/song/song-list/SongListSorter.spec.ts similarity index 58% rename from resources/assets/js/components/song/SongListSorter.spec.ts rename to resources/assets/js/components/song/song-list/SongListSorter.spec.ts index 0e40c6e6..da9c0cc9 100644 --- a/resources/assets/js/components/song/SongListSorter.spec.ts +++ b/resources/assets/js/components/song/song-list/SongListSorter.spec.ts @@ -1,6 +1,7 @@ import { screen } from '@testing-library/vue' import { expect, it } from 'vitest' import UnitTestCase from '@/__tests__/UnitTestCase' +import { useLocalStorage } from '@/composables/useLocalStorage' import Component from './SongListSorter.vue' new class extends UnitTestCase { @@ -47,5 +48,39 @@ new class extends UnitTestCase { screen.getByText('Custom Order') }) + + it('has a checkbox to toggle the column visibility', async () => { + this.be().render(Component) + + ;['Album', 'Track & Disc', 'Time'].forEach(text => screen.getByTitle(`Click to toggle the ${text} column`)) + + await this.user.click(screen.getByTitle('Click to toggle the Album column')) + + expect(useLocalStorage().get('playable-list-columns')).toEqual( + ['track', 'title', 'artist', 'duration'], + ) + }) + + it('gets the column visibility from local storage', async () => { + // ensure the localstorage is properly namespaced + this.be() + + useLocalStorage().set('playable-list-columns', ['track']) + this.render(Component) + + ;[{ + title: 'Track & Disc', + checked: true, + }, { + title: 'Album', + checked: false, + }, { + title: 'Time', + checked: true, + }].forEach(({ title, checked }) => { + const el: HTMLInputElement = screen.getByTitle(`Click to toggle the ${title} column`) + expect(el.checked).toBe(checked) + }) + }) } } diff --git a/resources/assets/js/components/song/SongListSorter.vue b/resources/assets/js/components/song/song-list/SongListSorter.vue similarity index 52% rename from resources/assets/js/components/song/SongListSorter.vue rename to resources/assets/js/components/song/song-list/SongListSorter.vue index 9823e9e2..ac03ea6e 100644 --- a/resources/assets/js/components/song/SongListSorter.vue +++ b/resources/assets/js/components/song/song-list/SongListSorter.vue @@ -9,10 +9,23 @@ v-for="item in menuItems" :key="item.label" :class="currentlySortedBy(item.field) && 'active'" - class="cursor-pointer flex justify-between" + class="cursor-pointer flex justify-between !pl-3 hover:!bg-white/10" @click="sort(item.field)" > - {{ item.label }} + + {{ item.label }}