mirror of
https://github.com/koel/koel
synced 2024-11-14 00:17:13 +00:00
feat: allow toggling song list columns (#1857)
This commit is contained in:
parent
3e9b94c099
commit
575551c903
26 changed files with 424 additions and 169 deletions
|
@ -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'))
|
||||
}
|
||||
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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'
|
||||
|
|
|
@ -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'
|
||||
|
||||
|
|
|
@ -1,9 +0,0 @@
|
|||
// Vitest Snapshot v1
|
||||
|
||||
exports[`renders 1`] = `
|
||||
<div class="song-list-wrap relative flex flex-col flex-1 overflow-auto py-0 px-3 md:p-0" data-testid="song-list" tabindex="0">
|
||||
<div class="sortable song-list-header flex z-[2] bg-k-bg-secondary"><span class="track-number" data-testid="header-track-number" role="button" title="Sort by track number"> # <!--v-if--><!--v-if--></span><span class="title-artist" data-testid="header-title" role="button" title="Sort by title"> Title <br data-testid="Icon" icon="[object Object]" class="text-k-highlight"><!--v-if--></span><span class="album" data-testid="header-album" role="button" title="Sort by album">Album<span class="ml-2"><!--v-if--><!--v-if--></span></span>
|
||||
<!--v-if--><span class="time" data-testid="header-length" role="button" title="Sort by duration"> Time <!--v-if--><!--v-if--></span><span class="extra"><br data-testid="song-list-sorter" field="title" order="asc" content-type="songs"></span>
|
||||
</div><br data-testid="virtual-scroller" item-height="64" items="[object Object],[object Object],[object Object],[object Object],[object Object]">
|
||||
</div>
|
||||
`;
|
|
@ -1,10 +0,0 @@
|
|||
// Vitest Snapshot v1
|
||||
|
||||
exports[`renders 1`] = `
|
||||
<div data-v-9a89d9b9="">
|
||||
<!--v-if-->
|
||||
<article data-v-9a89d9b9="" class="playing song-item group text-k-text-secondary border-b border-k-border !max-w-full h-[64px] flex items-center transition-[background-color,_box-shadow] ease-in-out duration-200 focus:rounded-md focus focus-within:rounded-md focus:ring-inset focus:ring-1 focus:!ring-k-accent focus-within:ring-inset focus-within:ring-1 focus-within:!ring-k-accent hover:bg-white/5 hover:ring-inset hover:ring-1 hover:ring-white/10 hover:rounded-md" data-testid="song-item" tabindex="0"><span data-v-9a89d9b9="" class="track-number"><i data-v-47e95701="" data-v-9a89d9b9="" class="relative flex gap-1 content-between w-[13px] aspect-square"><span data-v-47e95701=""></span><span data-v-47e95701=""></span><span data-v-47e95701=""></span></i></span><span data-v-9a89d9b9="" class="thumbnail leading-none"><button data-v-9a89d9b9="" style="background-image: url(undefined/resources/assets/img/covers/default.svg);" title="Pause" class="song-thumbnail w-[48px] aspect-square bg-cover relative rounded overflow-hidden active:scale-95"><img alt="Cover image" src="https://example.com/cover.jpg" class="w-full aspect-square object-cover" loading="lazy"><span class="absolute top-0 left-0 w-full h-full group-hover:bg-black/40 no-hover:bg-black/40 z-10"></span><span class="absolute flex opacity-0 no-hover:opacity-100 items-center justify-center w-[24px] aspect-square rounded-full top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 bg-k-highlight group-hover:opacity-100 duration-500 transition z-20"><br data-testid="Icon" icon="[object Object]" class="text-white"></span></button></span><span data-v-9a89d9b9="" class="title-artist flex flex-col gap-2 overflow-hidden"><span data-v-9a89d9b9="" class="title text-k-text-primary !flex gap-2 items-center"><!--v-if--> Test Song</span><span data-v-9a89d9b9="" class="artist">Test Artist</span></span><span data-v-9a89d9b9="" class="album">Test Album</span>
|
||||
<!--v-if--><span data-v-9a89d9b9="" class="time">16:40</span><span data-v-9a89d9b9="" class="extra"><button data-v-9a89d9b9="" class="transition-[color] duration-200 ease-in-out text-k-text-secondary hover:text-k-text-primary" type="button" title="Unlike"><br data-testid="Icon" icon="[object Object]"></button></span>
|
||||
</article>
|
||||
</div>
|
||||
`;
|
|
@ -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<Playable>,
|
||||
config: Partial<PlayableListConfig> = {
|
||||
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: {
|
||||
[<symbol>PlayablesKey]: [ref(songs)],
|
||||
[<symbol>SelectedPlayablesKey]: [ref(selectedPlayables), (value: Playable[]) => (selectedPlayables = value)],
|
||||
[<symbol>PlayableListConfigKey]: [config],
|
||||
[<symbol>PlayableListContextKey]: [context],
|
||||
[<symbol>PlayableListSortFieldKey]: [sortFieldRef, (value: PlayableListSortField) => (sortFieldRef.value = value)],
|
||||
[<symbol>SongListSortOrderKey]: [sortOrderRef, (value: SortOrder) => (sortOrderRef.value = value)],
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
|
@ -8,80 +8,7 @@
|
|||
@keydown.enter.prevent.stop="handleEnter"
|
||||
@keydown.a.prevent="handleA"
|
||||
>
|
||||
<div
|
||||
:class="config.sortable ? 'sortable' : 'unsortable'"
|
||||
class="song-list-header flex z-[2] bg-k-bg-secondary"
|
||||
>
|
||||
<span
|
||||
class="track-number"
|
||||
data-testid="header-track-number"
|
||||
role="button"
|
||||
title="Sort by track number"
|
||||
@click="sort('track')"
|
||||
>
|
||||
#
|
||||
<template v-if="config.sortable">
|
||||
<Icon v-if="sortField === 'track' && sortOrder === 'asc'" :icon="faCaretUp" class="text-k-highlight" />
|
||||
<Icon v-if="sortField === 'track' && sortOrder === 'desc'" :icon="faCaretDown" class="text-k-highlight" />
|
||||
</template>
|
||||
</span>
|
||||
<span
|
||||
class="title-artist"
|
||||
data-testid="header-title"
|
||||
role="button"
|
||||
title="Sort by title"
|
||||
@click="sort('title')"
|
||||
>
|
||||
Title
|
||||
<template v-if="config.sortable">
|
||||
<Icon v-if="sortField === 'title' && sortOrder === 'asc'" :icon="faCaretUp" class="text-k-highlight" />
|
||||
<Icon v-if="sortField === 'title' && sortOrder === 'desc'" :icon="faCaretDown" class="text-k-highlight" />
|
||||
</template>
|
||||
</span>
|
||||
<span
|
||||
class="album"
|
||||
data-testid="header-album"
|
||||
role="button"
|
||||
:title="`Sort by ${contentType === 'episodes' ? 'podcast' : (contentType === 'songs' ? 'album' : 'album/podcast')}`"
|
||||
@click="sort(contentType === 'episodes' ? 'podcast_title' : (contentType === 'songs' ? 'album_name' : ['album_name', 'podcast_title']))"
|
||||
>
|
||||
<template v-if="contentType === 'episodes'">Podcast</template>
|
||||
<template v-else-if="contentType === 'songs'">Album</template>
|
||||
<template v-else>Album <span class="opacity-50">/</span> Podcast</template>
|
||||
|
||||
<span v-if="config.sortable" class="ml-2">
|
||||
<Icon v-if="sortingByAlbumOrPodcast && sortOrder === 'asc'" :icon="faCaretUp" class="text-k-highlight" />
|
||||
<Icon v-if="sortingByAlbumOrPodcast && sortOrder === 'desc'" :icon="faCaretDown" class="text-k-highlight" />
|
||||
</span>
|
||||
</span>
|
||||
<template v-if="config.collaborative">
|
||||
<span class="collaborator">User</span>
|
||||
<span class="added-at">Added</span>
|
||||
</template>
|
||||
<span
|
||||
class="time"
|
||||
data-testid="header-length"
|
||||
role="button"
|
||||
title="Sort by duration"
|
||||
@click="sort('length')"
|
||||
>
|
||||
Time
|
||||
<template v-if="config.sortable">
|
||||
<Icon v-if="sortField === 'length' && sortOrder === 'asc'" :icon="faCaretUp" class="text-k-highlight" />
|
||||
<Icon v-if="sortField === 'length' && sortOrder === 'desc'" :icon="faCaretDown" class="text-k-highlight" />
|
||||
</template>
|
||||
</span>
|
||||
<span class="extra">
|
||||
<SongListSorter
|
||||
v-if="config.sortable"
|
||||
:field="sortField"
|
||||
:has-custom-order-sort="config.hasCustomOrderSort"
|
||||
:order="sortOrder"
|
||||
:content-type="contentType"
|
||||
@sort="sort"
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
<SongListHeader :content-type="contentType" @sort="sort" />
|
||||
|
||||
<VirtualScroller
|
||||
v-slot="{ item }: { item: PlayableRow }"
|
||||
|
@ -111,11 +38,10 @@
|
|||
<script lang="ts" setup>
|
||||
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<Playable[]>]>(PlayablesKey)
|
||||
const [selectedPlayables, setSelectedPlayables] = requireInjection<[Ref<Playable[]>, Closure]>(SelectedPlayablesKey)
|
||||
const [sortField, setSortField] = requireInjection<[Ref<MaybeArray<PlayableListSortField>>, Closure]>(PlayableListSortFieldKey)
|
||||
const [sortOrder, setSortOrder] = requireInjection<[Ref<SortOrder>, Closure]>(SongListSortOrderKey)
|
||||
const [sortField] = requireInjection<[Ref<MaybeArray<PlayableListSortField>>, Closure]>(PlayableListSortFieldKey)
|
||||
const [config] = requireInjection<[Partial<PlayableListConfig>]>(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<PlayableListSortField>) => {
|
||||
// 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<PlayableListSortField>, 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 {
|
|
@ -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')
|
||||
|
|
@ -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<Playable>,
|
||||
config: Partial<PlayableListConfig> = {
|
||||
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: {
|
||||
[<symbol>PlayablesKey]: [ref(songs)],
|
||||
[<symbol>SelectedPlayablesKey]: [ref(selectedPlayables), (value: Playable[]) => (selectedPlayables = value)],
|
||||
[<symbol>PlayableListConfigKey]: [config],
|
||||
[<symbol>PlayableListContextKey]: [context],
|
124
resources/assets/js/components/song/song-list/SongListHeader.vue
Normal file
124
resources/assets/js/components/song/song-list/SongListHeader.vue
Normal file
|
@ -0,0 +1,124 @@
|
|||
<template>
|
||||
<div
|
||||
:class="config.sortable ? 'sortable' : 'unsortable'"
|
||||
class="song-list-header flex z-[2] bg-k-bg-secondary pl-5"
|
||||
>
|
||||
<span
|
||||
v-if="shouldShowColumn('track')"
|
||||
class="track-number"
|
||||
data-testid="header-track-number"
|
||||
role="button"
|
||||
title="Sort by track number"
|
||||
@click="sort('track')"
|
||||
>
|
||||
#
|
||||
<template v-if="config.sortable">
|
||||
<Icon v-if="sortField === 'track' && sortOrder === 'asc'" :icon="faCaretUp" class="text-k-highlight" />
|
||||
<Icon v-if="sortField === 'track' && sortOrder === 'desc'" :icon="faCaretDown" class="text-k-highlight" />
|
||||
</template>
|
||||
</span>
|
||||
<span
|
||||
class="title-artist"
|
||||
data-testid="header-title"
|
||||
role="button"
|
||||
title="Sort by title"
|
||||
@click="sort('title')"
|
||||
>
|
||||
Title
|
||||
<template v-if="config.sortable">
|
||||
<Icon v-if="sortField === 'title' && sortOrder === 'asc'" :icon="faCaretUp" class="text-k-highlight" />
|
||||
<Icon v-if="sortField === 'title' && sortOrder === 'desc'" :icon="faCaretDown" class="text-k-highlight" />
|
||||
</template>
|
||||
</span>
|
||||
<span
|
||||
v-if="shouldShowColumn('album')"
|
||||
:title="`Sort by ${contentType === 'episodes' ? 'podcast' : (contentType === 'songs' ? 'album' : 'album/podcast')}`"
|
||||
class="album"
|
||||
data-testid="header-album"
|
||||
role="button"
|
||||
@click="sort(contentType === 'episodes' ? 'podcast_title' : (contentType === 'songs' ? 'album_name' : ['album_name', 'podcast_title']))"
|
||||
>
|
||||
<template v-if="contentType === 'episodes'">Podcast</template>
|
||||
<template v-else-if="contentType === 'songs'">Album</template>
|
||||
<template v-else>Album <span class="opacity-50">/</span> Podcast</template>
|
||||
|
||||
<span v-if="config.sortable" class="ml-2">
|
||||
<Icon v-if="sortingByAlbumOrPodcast && sortOrder === 'asc'" :icon="faCaretUp" class="text-k-highlight" />
|
||||
<Icon v-if="sortingByAlbumOrPodcast && sortOrder === 'desc'" :icon="faCaretDown" class="text-k-highlight" />
|
||||
</span>
|
||||
</span>
|
||||
<template v-if="config.collaborative">
|
||||
<span class="collaborator">User</span>
|
||||
<span class="added-at">Added</span>
|
||||
</template>
|
||||
<span
|
||||
v-if="shouldShowColumn('duration')"
|
||||
class="time"
|
||||
data-testid="header-length"
|
||||
role="button"
|
||||
title="Sort by duration"
|
||||
@click="sort('length')"
|
||||
>
|
||||
Time
|
||||
<template v-if="config.sortable">
|
||||
<Icon v-if="sortField === 'length' && sortOrder === 'asc'" :icon="faCaretUp" class="text-k-highlight" />
|
||||
<Icon v-if="sortField === 'length' && sortOrder === 'desc'" :icon="faCaretDown" class="text-k-highlight" />
|
||||
</template>
|
||||
</span>
|
||||
<span class="extra">
|
||||
<SongListSorter
|
||||
v-if="config.sortable"
|
||||
:field="sortField"
|
||||
:has-custom-order-sort="config.hasCustomOrderSort"
|
||||
:order="sortOrder"
|
||||
:content-type="contentType"
|
||||
@sort="sort"
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { Ref } from 'vue'
|
||||
import { computed } from 'vue'
|
||||
import { faCaretDown, faCaretUp } from '@fortawesome/free-solid-svg-icons'
|
||||
import { arrayify, requireInjection } from '@/utils/helpers'
|
||||
import { PlayableListConfigKey, PlayableListSortFieldKey, SongListSortOrderKey } from '@/symbols'
|
||||
import type { getPlayableCollectionContentType } from '@/utils/typeGuards'
|
||||
import { usePlayableListColumnVisibility } from '@/composables/usePlayableListColumnVisibility'
|
||||
|
||||
import SongListSorter from '@/components/song/song-list/SongListSorter.vue'
|
||||
|
||||
withDefaults(defineProps<{
|
||||
contentType?: ReturnType<getPlayableCollectionContentType>
|
||||
}>(), {
|
||||
contentType: 'songs',
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'sort', field: MaybeArray<PlayableListSortField>, order: SortOrder): void
|
||||
}>()
|
||||
|
||||
const { shouldShowColumn } = usePlayableListColumnVisibility()
|
||||
|
||||
const [sortField, setSortField] = requireInjection<[Ref<MaybeArray<PlayableListSortField>>, Closure]>(PlayableListSortFieldKey)
|
||||
const [sortOrder, setSortOrder] = requireInjection<[Ref<SortOrder>, Closure]>(SongListSortOrderKey)
|
||||
const [config] = requireInjection<[Partial<PlayableListConfig>]>(PlayableListConfigKey, [{}])
|
||||
|
||||
const sort = (field: MaybeArray<PlayableListSortField>) => {
|
||||
// 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 sortingByAlbumOrPodcast = computed(() => {
|
||||
const sortFields = arrayify(sortField.value)
|
||||
return sortFields[0] === 'album_name' || sortFields[0] === 'podcast_title'
|
||||
})
|
||||
</script>
|
|
@ -2,14 +2,14 @@
|
|||
<div>
|
||||
<h4
|
||||
v-if="showDisc && item.playable.disc"
|
||||
class="title text-k-text-primary !flex gap-2 p-2 uppercase"
|
||||
class="title text-k-text-primary !flex gap-2 p-2 uppercase pl-5"
|
||||
>
|
||||
Disc {{ item.playable.disc }}
|
||||
</h4>
|
||||
|
||||
<article
|
||||
:class="{ playing, external, selected: item.selected }"
|
||||
class="song-item group text-k-text-secondary border-b border-k-border !max-w-full h-[64px] flex
|
||||
class="song-item group pl-5 text-k-text-secondary border-b border-k-border !max-w-full h-[64px] flex
|
||||
items-center transition-[background-color,_box-shadow] ease-in-out duration-200
|
||||
focus:rounded-md focus focus-within:rounded-md focus:ring-inset focus:ring-1 focus:!ring-k-accent
|
||||
focus-within:ring-inset focus-within:ring-1 focus-within:!ring-k-accent
|
||||
|
@ -18,7 +18,7 @@
|
|||
tabindex="0"
|
||||
@dblclick.prevent.stop="play"
|
||||
>
|
||||
<span class="track-number">
|
||||
<span v-if="shouldShowColumn('track')" class="track-number">
|
||||
<SoundBars v-if="playable.playback_state === 'Playing'" />
|
||||
<span v-else class="text-k-text-secondary">
|
||||
<template v-if="isSong(playable)">{{ playable.track || '' }}</template>
|
||||
|
@ -35,14 +35,14 @@
|
|||
</span>
|
||||
<span class="artist">{{ artist }}</span>
|
||||
</span>
|
||||
<span class="album">{{ album }}</span>
|
||||
<span v-if="shouldShowColumn('album')" class="album">{{ album }}</span>
|
||||
<template v-if="config.collaborative">
|
||||
<span class="collaborator">
|
||||
<UserAvatar :user="collaborator" width="24" />
|
||||
</span>
|
||||
<span :title="playable.collaboration.added_at" class="added-at">{{ playable.collaboration.fmt_added_at }}</span>
|
||||
</template>
|
||||
<span class="time">{{ fmtLength }}</span>
|
||||
<span v-if="shouldShowColumn('duration')" class="time">{{ fmtLength }}</span>
|
||||
<span class="extra">
|
||||
<LikeButton :playable="playable" />
|
||||
</span>
|
||||
|
@ -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<PlayableListConfig>]>(PlayableListCon
|
|||
|
||||
const { currentUser } = useAuthorization()
|
||||
const { isPlus } = useKoelPlus()
|
||||
const { shouldShowColumn } = usePlayableListColumnVisibility()
|
||||
|
||||
const { item } = toRefs(props)
|
||||
|
|
@ -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(
|
||||
<PlayableListColumnName[]>['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)
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
|
@ -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)"
|
||||
>
|
||||
<span>{{ item.label }}</span>
|
||||
<label
|
||||
v-if="shouldShowColumnVisibilityCheckboxes()"
|
||||
class="w-4 mr-2.5 flex items-center"
|
||||
@click.stop="item.visibilityToggleable && toggleColumn(item.column)"
|
||||
>
|
||||
<input
|
||||
:checked="shouldShowColumn(item.column)"
|
||||
:disabled="!item.visibilityToggleable"
|
||||
:title="item.visibilityToggleable ? `Click to toggle the ${item.label} column` : ''"
|
||||
class="disabled:opacity-20 bg-white h-4 aspect-square rounded checked:border-white/75 checked:border-2 checked:bg-k-highlight"
|
||||
type="checkbox"
|
||||
>
|
||||
</label>
|
||||
<span class="flex-1 text-left">{{ item.label }}</span>
|
||||
<span class="icon hidden ml-3">
|
||||
<Icon v-if="field === 'position'" :icon="faCheck" />
|
||||
<Icon v-else-if="order === 'asc'" :icon="faArrowUp" />
|
||||
|
@ -32,6 +45,7 @@ import { computed, onBeforeUnmount, onMounted, ref, toRefs } from 'vue'
|
|||
import { useFloatingUi } from '@/composables/useFloatingUi'
|
||||
import { arrayify } from '@/utils/helpers'
|
||||
import type { getPlayableCollectionContentType } from '@/utils/typeGuards'
|
||||
import { usePlayableListColumnVisibility } from '@/composables/usePlayableListColumnVisibility'
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
field?: MaybeArray<PlayableListSortField> // the current field(s) being sorted by
|
||||
|
@ -47,30 +61,57 @@ const props = withDefaults(defineProps<{
|
|||
|
||||
const emit = defineEmits<{ (e: 'sort', field: MaybeArray<PlayableListSortField>): void }>()
|
||||
|
||||
interface MenuItem {
|
||||
column?: PlayableListColumnName
|
||||
label: string
|
||||
field: MaybeArray<PlayableListSortField>
|
||||
visibilityToggleable: boolean
|
||||
}
|
||||
|
||||
const {
|
||||
shouldShowColumn,
|
||||
toggleColumn,
|
||||
isConfigurable: shouldShowColumnVisibilityCheckboxes,
|
||||
} = usePlayableListColumnVisibility()
|
||||
|
||||
const { field, order, hasCustomOrderSort, contentType } = toRefs(props)
|
||||
|
||||
const button = ref<HTMLButtonElement>()
|
||||
const menu = ref<HTMLDivElement>()
|
||||
|
||||
const menuItems = computed(() => {
|
||||
interface MenuItems {
|
||||
label: string
|
||||
field: MaybeArray<PlayableListSortField>
|
||||
const title: MenuItem = { column: 'title', label: 'Title', field: 'title', visibilityToggleable: false }
|
||||
const artist: MenuItem = { label: 'Artist', field: 'artist_name', visibilityToggleable: false }
|
||||
const author: MenuItem = { label: 'Author', field: 'podcast_author', visibilityToggleable: false }
|
||||
|
||||
const artistOrAuthor: MenuItem = {
|
||||
label: 'Artist or Author',
|
||||
field: ['artist_name', 'podcast_author'],
|
||||
visibilityToggleable: false,
|
||||
}
|
||||
|
||||
const title: MenuItems = { label: 'Title', field: 'title' }
|
||||
const artist: MenuItems = { label: 'Artist', field: 'artist_name' }
|
||||
const author: MenuItems = { label: 'Author', field: 'podcast_author' }
|
||||
const artistOrAuthor: MenuItems = { label: 'Artist or Author', field: ['artist_name', 'podcast_author'] }
|
||||
const album: MenuItems = { label: 'Album', field: 'album_name' }
|
||||
const track: MenuItems = { label: 'Track & Disc', field: 'track' }
|
||||
const time: MenuItems = { label: 'Time', field: 'length' }
|
||||
const dateAdded: MenuItems = { label: 'Date Added', field: 'created_at' }
|
||||
const podcast: MenuItems = { label: 'Podcast', field: 'podcast_title' }
|
||||
const albumOrPodcast: MenuItems = { label: 'Album or Podcast', field: ['album_name', 'podcast_title'] }
|
||||
const customOrder: MenuItems = { label: 'Custom Order', field: 'position' }
|
||||
const album: MenuItem = { column: 'album', label: 'Album', field: 'album_name', visibilityToggleable: true }
|
||||
const track: MenuItem = { column: 'track', label: 'Track & Disc', field: 'track', visibilityToggleable: true }
|
||||
const time: MenuItem = { column: 'duration', label: 'Time', field: 'length', visibilityToggleable: true }
|
||||
|
||||
let items: MenuItems[] = [title, album, artist, track, time, dateAdded]
|
||||
const dateAdded: MenuItem = {
|
||||
label: 'Date Added',
|
||||
field: 'created_at',
|
||||
visibilityToggleable: false,
|
||||
}
|
||||
|
||||
const podcast: MenuItem = { column: 'album', label: 'Podcast', field: 'podcast_title', visibilityToggleable: true }
|
||||
|
||||
const albumOrPodcast: MenuItem = {
|
||||
column: 'album',
|
||||
label: 'Album or Podcast',
|
||||
field: ['album_name', 'podcast_title'],
|
||||
visibilityToggleable: true,
|
||||
}
|
||||
|
||||
const customOrder: MenuItem = { label: 'Custom Order', field: 'position', visibilityToggleable: false }
|
||||
|
||||
let items: MenuItem[] = [title, album, artist, track, time, dateAdded]
|
||||
|
||||
if (contentType.value === 'episodes') {
|
||||
items = [title, podcast, author, time, dateAdded]
|
|
@ -0,0 +1,3 @@
|
|||
// Vitest Snapshot v1
|
||||
|
||||
exports[`renders 1`] = `<div class="song-list-wrap relative flex flex-col flex-1 overflow-auto py-0 px-3 md:p-0" data-testid="song-list" tabindex="0"><br data-testid="song-list-header" content-type="songs"><br data-testid="virtual-scroller" item-height="64" items="[object Object],[object Object],[object Object],[object Object],[object Object]"></div>`;
|
|
@ -0,0 +1,7 @@
|
|||
// Vitest Snapshot v1
|
||||
|
||||
exports[`renders 1`] = `
|
||||
<div class="sortable song-list-header flex z-[2] bg-k-bg-secondary pl-5"><span class="track-number" data-testid="header-track-number" role="button" title="Sort by track number"> # <!--v-if--><!--v-if--></span><span class="title-artist" data-testid="header-title" role="button" title="Sort by title"> Title <br data-testid="Icon" icon="[object Object]" class="text-k-highlight"><!--v-if--></span><span title="Sort by album" class="album" data-testid="header-album" role="button">Album<span class="ml-2"><!--v-if--><!--v-if--></span></span>
|
||||
<!--v-if--><span class="time" data-testid="header-length" role="button" title="Sort by duration"> Time <!--v-if--><!--v-if--></span><span class="extra"><br data-testid="song-list-sorter" field="title" order="asc" content-type="songs"></span>
|
||||
</div>
|
||||
`;
|
|
@ -0,0 +1,10 @@
|
|||
// Vitest Snapshot v1
|
||||
|
||||
exports[`renders 1`] = `
|
||||
<div data-v-8a9b98e4="">
|
||||
<!--v-if-->
|
||||
<article data-v-8a9b98e4="" class="playing song-item group pl-5 text-k-text-secondary border-b border-k-border !max-w-full h-[64px] flex items-center transition-[background-color,_box-shadow] ease-in-out duration-200 focus:rounded-md focus focus-within:rounded-md focus:ring-inset focus:ring-1 focus:!ring-k-accent focus-within:ring-inset focus-within:ring-1 focus-within:!ring-k-accent hover:bg-white/5 hover:ring-inset hover:ring-1 hover:ring-white/10 hover:rounded-md" data-testid="song-item" tabindex="0"><span data-v-8a9b98e4="" class="track-number"><i data-v-47e95701="" data-v-8a9b98e4="" class="relative flex gap-1 content-between w-[13px] aspect-square"><span data-v-47e95701=""></span><span data-v-47e95701=""></span><span data-v-47e95701=""></span></i></span><span data-v-8a9b98e4="" class="thumbnail leading-none"><button data-v-8a9b98e4="" style="background-image: url(undefined/resources/assets/img/covers/default.svg);" title="Pause" class="song-thumbnail w-[48px] aspect-square bg-cover relative rounded overflow-hidden active:scale-95"><img alt="Cover image" src="https://example.com/cover.jpg" class="w-full aspect-square object-cover" loading="lazy"><span class="absolute top-0 left-0 w-full h-full group-hover:bg-black/40 no-hover:bg-black/40 z-10"></span><span class="absolute flex opacity-0 no-hover:opacity-100 items-center justify-center w-[24px] aspect-square rounded-full top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 bg-k-highlight group-hover:opacity-100 duration-500 transition z-20"><br data-testid="Icon" icon="[object Object]" class="text-white"></span></button></span><span data-v-8a9b98e4="" class="title-artist flex flex-col gap-2 overflow-hidden"><span data-v-8a9b98e4="" class="title text-k-text-primary !flex gap-2 items-center"><!--v-if--> Test Song</span><span data-v-8a9b98e4="" class="artist">Test Artist</span></span><span data-v-8a9b98e4="" class="album">Test Album</span>
|
||||
<!--v-if--><span data-v-8a9b98e4="" class="time">16:40</span><span data-v-8a9b98e4="" class="extra"><button data-v-8a9b98e4="" class="transition-[color] duration-200 ease-in-out text-k-text-secondary hover:text-k-text-primary" type="button" title="Unlike"><br data-testid="Icon" icon="[object Object]"></button></span>
|
||||
</article>
|
||||
</div>
|
||||
`;
|
|
@ -1,12 +1,11 @@
|
|||
import { useAuthorization } from '@/composables/useAuthorization'
|
||||
import { get as baseGet, remove as baseRemove, set as baseSet } from 'local-storage'
|
||||
|
||||
export const useLocalStorage = (namespaced = true) => {
|
||||
export const useLocalStorage = (namespaced = true, user?: User = null) => {
|
||||
let namespace = ''
|
||||
|
||||
if (namespaced) {
|
||||
const { currentUser } = useAuthorization()
|
||||
namespace = `${currentUser.value.id}::`
|
||||
namespace = user ? `${user.id}::` : `${useAuthorization().currentUser.value.id}::`
|
||||
}
|
||||
|
||||
const get = <T> (key: string, defaultValue: T | null = null): T | null => {
|
||||
|
|
|
@ -0,0 +1,63 @@
|
|||
import { ref } from 'vue'
|
||||
import { breakpointsTailwind, useBreakpoints } from '@vueuse/core'
|
||||
import { useLocalStorage } from '@/composables/useLocalStorage'
|
||||
import { logger } from '@/utils/logger'
|
||||
|
||||
const visibleColumns: Ref<PlayableListColumnName[]> = ref([])
|
||||
|
||||
export const usePlayableListColumnVisibility = () => {
|
||||
const collectVisibleColumns = () => {
|
||||
const validColumns: PlayableListColumnName[] = ['track', 'title', 'artist', 'album', 'duration', 'play_count']
|
||||
const defaultColumns: PlayableListColumnName[] = ['track', 'title', 'artist', 'album', 'duration']
|
||||
|
||||
try {
|
||||
let columns = useLocalStorage().get<PlayableListColumnName[]>('playable-list-columns', defaultColumns)!
|
||||
|
||||
columns = columns.filter(column => validColumns.includes(column))
|
||||
|
||||
// Ensure 'title' is always visible
|
||||
columns.push('title')
|
||||
return Array.from(new Set(columns))
|
||||
} catch (error: unknown) {
|
||||
process.env.NODE_ENV !== 'test' && logger.error('Failed to load columns from local storage', error)
|
||||
return defaultColumns
|
||||
}
|
||||
}
|
||||
|
||||
if (!visibleColumns.value.length) {
|
||||
visibleColumns.value = collectVisibleColumns()
|
||||
}
|
||||
|
||||
const isConfigurable = () => {
|
||||
const breakpoints = useBreakpoints(breakpointsTailwind)
|
||||
return breakpoints.isGreaterOrEqual('md')
|
||||
}
|
||||
|
||||
const shouldShowColumn = (name: PlayableListColumnName) => {
|
||||
if (!isConfigurable()) {
|
||||
// on a smaller screen we render the columns nonetheless and let CSS handles their visibility
|
||||
return true
|
||||
}
|
||||
|
||||
return visibleColumns.value.includes(name)
|
||||
}
|
||||
|
||||
const toggleColumn = (column: PlayableListColumnName) => {
|
||||
let columns = visibleColumns.value
|
||||
|
||||
if (!columns.includes(column)) {
|
||||
columns.push(column)
|
||||
} else {
|
||||
columns = columns.filter(c => c !== column)
|
||||
}
|
||||
|
||||
visibleColumns.value = columns
|
||||
useLocalStorage().set('playable-list-columns', columns)
|
||||
}
|
||||
|
||||
return {
|
||||
shouldShowColumn,
|
||||
toggleColumn,
|
||||
isConfigurable,
|
||||
}
|
||||
}
|
|
@ -22,7 +22,7 @@ import {
|
|||
} from '@/symbols'
|
||||
|
||||
import ControlsToggle from '@/components/ui/ScreenControlsToggle.vue'
|
||||
import SongList from '@/components/song/SongList.vue'
|
||||
import SongList from '@/components/song/song-list/SongList.vue'
|
||||
import ThumbnailStack from '@/components/ui/ThumbnailStack.vue'
|
||||
|
||||
export const useSongList = (
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
import { merge } from 'lodash'
|
||||
import { reactive } from 'vue'
|
||||
|
||||
import SongListControls from '@/components/song/SongListControls.vue'
|
||||
import SongListControls from '@/components/song/song-list/SongListControls.vue'
|
||||
|
||||
export const useSongListControls = (
|
||||
screen: ScreenName,
|
||||
|
|
2
resources/assets/js/types.d.ts
vendored
2
resources/assets/js/types.d.ts
vendored
|
@ -517,3 +517,5 @@ interface Visualizer {
|
|||
url: string
|
||||
}
|
||||
}
|
||||
|
||||
type PlayableListColumnName = 'title' | 'album' | 'track' | 'duration' | 'created_at' | 'play_count'
|
||||
|
|
Loading…
Reference in a new issue