feat: allow toggling song list columns (#1857)

This commit is contained in:
Phan An 2024-10-24 16:27:17 +07:00 committed by GitHub
parent 3e9b94c099
commit 575551c903
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
26 changed files with 424 additions and 169 deletions

View file

@ -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'))
}

View file

@ -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()

View file

@ -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'

View file

@ -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'

View file

@ -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>
`;

View file

@ -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>
`;

View file

@ -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)],
},
},
})
}
}

View file

@ -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 {

View file

@ -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')

View file

@ -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],

View 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>

View file

@ -2,23 +2,23 @@
<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
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"
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
hover:bg-white/5 hover:ring-inset hover:ring-1 hover:ring-white/10 hover:rounded-md"
data-testid="song-item"
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)

View file

@ -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)
})
})
}
}

View file

@ -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]

View file

@ -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>`;

View file

@ -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>
`;

View file

@ -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>
`;

View file

@ -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 => {

View file

@ -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,
}
}

View file

@ -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 = (

View file

@ -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,

View file

@ -517,3 +517,5 @@ interface Visualizer {
url: string
}
}
type PlayableListColumnName = 'title' | 'album' | 'track' | 'duration' | 'created_at' | 'play_count'