test: add tests for playlist folder functionalities

This commit is contained in:
Phan An 2022-09-08 12:06:49 +07:00
parent e8a1cdece7
commit 1730e19d21
No known key found for this signature in database
GPG key ID: A81E4477F0BB6FDC
58 changed files with 779 additions and 296 deletions

View file

@ -27,7 +27,7 @@ class PlaylistFolderController extends Controller
{
$this->authorize('own', $playlistFolder);
return PlaylistFolderResource::make($this->service->updateFolder($playlistFolder, $request->name));
return PlaylistFolderResource::make($this->service->renameFolder($playlistFolder, $request->name));
}
public function destroy(PlaylistFolder $playlistFolder)

View file

@ -33,7 +33,7 @@ class PlaylistFolder extends Model
public function playlists(): HasMany
{
return $this->hasMany(Playlist::class);
return $this->hasMany(Playlist::class, 'folder_id');
}
public function user(): BelongsTo

View file

@ -13,7 +13,7 @@ class PlaylistFolderService
return $user->playlist_folders()->create(['name' => $name]);
}
public function updateFolder(PlaylistFolder $folder, string $name): PlaylistFolder
public function renameFolder(PlaylistFolder $folder, string $name): PlaylistFolder
{
$folder->update(['name' => $name]);

View file

@ -42,7 +42,7 @@ class PlaylistService
$playlist->songs()->detach($songIds);
}
/** @deprecated */
/** @deprecated since v6.0.0, use add/removeSongs methods instead */
public function populatePlaylist(Playlist $playlist, array $songIds): void
{
$playlist->songs()->sync($songIds);

View file

@ -1,28 +1,26 @@
<template>
<Overlay/>
<DialogBox ref="dialog"/>
<MessageToaster ref="toaster"/>
<div id="main" v-if="authenticated">
<Hotkeys/>
<EventListeners/>
<GlobalEventListeners/>
<AppHeader/>
<MainWrapper/>
<AppFooter/>
<SupportKoel/>
<SongContextMenu/>
<AlbumContextMenu/>
<ArtistContextMenu/>
<PlaylistContextMenu/>
<PlaylistFolderContextMenu/>
<CreateNewPlaylistContextMenu/>
</div>
<template v-else>
<div class="login-wrapper">
<LoginForm @loggedin="onUserLoggedIn"/>
</div>
</template>
<SongContextMenu/>
<AlbumContextMenu/>
<ArtistContextMenu/>
<PlaylistFolderContextMenu/>
<DialogBox ref="dialog"/>
<MessageToaster ref="toaster"/>
<div class="login-wrapper" v-else>
<LoginForm @loggedin="onUserLoggedIn"/>
</div>
</template>
<script lang="ts" setup>
@ -32,24 +30,29 @@ import { commonStore, preferenceStore as preferences } from '@/stores'
import { authService, playbackService, socketListener, socketService, uploadService } from '@/services'
import { DialogBoxKey, MessageToasterKey } from '@/symbols'
import AppHeader from '@/components/layout/AppHeader.vue'
import AppFooter from '@/components/layout/app-footer/index.vue'
import EventListeners from '@/components/utils/EventListeners.vue'
import Hotkeys from '@/components/utils/HotkeyListener.vue'
import LoginForm from '@/components/auth/LoginForm.vue'
import MainWrapper from '@/components/layout/main-wrapper/index.vue'
import Overlay from '@/components/ui/Overlay.vue'
import AlbumContextMenu from '@/components/album/AlbumContextMenu.vue'
import ArtistContextMenu from '@/components/artist/ArtistContextMenu.vue'
import PlaylistFolderContextMenu from '@/components/playlist/PlaylistFolderContextMenu.vue'
import SongContextMenu from '@/components/song/SongContextMenu.vue'
import DialogBox from '@/components/ui/DialogBox.vue'
import MessageToaster from '@/components/ui/MessageToaster.vue'
import Overlay from '@/components/ui/Overlay.vue'
// Do not dynamic-import app footer, as it contains the <audio> element
// that is necessary to properly initialize the playService and equalizer.
import AppFooter from '@/components/layout/app-footer/index.vue'
const AppHeader = defineAsyncComponent(() => import('@/components/layout/AppHeader.vue'))
const GlobalEventListeners = defineAsyncComponent(() => import('@/components/utils/GlobalEventListeners.vue'))
const Hotkeys = defineAsyncComponent(() => import('@/components/utils/HotkeyListener.vue'))
const LoginForm = defineAsyncComponent(() => import('@/components/auth/LoginForm.vue'))
const MainWrapper = defineAsyncComponent(() => import('@/components/layout/main-wrapper/index.vue'))
const AlbumContextMenu = defineAsyncComponent(() => import('@/components/album/AlbumContextMenu.vue'))
const ArtistContextMenu = defineAsyncComponent(() => import('@/components/artist/ArtistContextMenu.vue'))
const PlaylistContextMenu = defineAsyncComponent(() => import('@/components/playlist/PlaylistContextMenu.vue'))
const PlaylistFolderContextMenu = defineAsyncComponent(() => import('@/components/playlist/PlaylistFolderContextMenu.vue'))
const SongContextMenu = defineAsyncComponent(() => import('@/components/song/SongContextMenu.vue'))
const CreateNewPlaylistContextMenu = defineAsyncComponent(() => import('@/components/playlist/CreateNewPlaylistContextMenu.vue'))
const SupportKoel = defineAsyncComponent(() => import('@/components/meta/SupportKoel.vue'))
const dialog = ref<InstanceType<typeof DialogBox>>()
const toaster = ref<InstanceType<typeof MessageToast>>()
const toaster = ref<InstanceType<typeof MessageToaster>>()
const authenticated = ref(false)
/**
@ -61,9 +64,9 @@ const requestNotificationPermission = async () => {
}
}
const onUserLoggedIn = () => {
const onUserLoggedIn = async () => {
authenticated.value = true
init()
await init()
}
onMounted(async () => {
@ -149,6 +152,7 @@ provide(MessageToasterKey, toaster)
display: flex;
height: 100vh;
flex-direction: column;
justify-content: flex-end;
}
.login-wrapper {

View file

@ -94,11 +94,15 @@ export default abstract class UnitTestCase {
options.global = options.global || {}
options.global.provide = options.global.provide || {}
// @ts-ignore
if (!options.global.provide?.hasOwnProperty(DialogBoxKey)) {
// @ts-ignore
options.global.provide[DialogBoxKey] = DialogBoxStub
}
// @ts-ignore
if (!options.global.provide?.hasOwnProperty(MessageToasterKey)) {
// @ts-ignore
options.global.provide[MessageToasterKey] = MessageToasterStub
}

View file

@ -6,6 +6,7 @@ import interactionFactory from '@/__tests__/factory/interactionFactory'
import smartPlaylistRuleFactory from '@/__tests__/factory/smartPlaylistRuleFactory'
import smartPlaylistRuleGroupFactory from '@/__tests__/factory/smartPlaylistRuleGroupFactory'
import playlistFactory, { states as playlistStates } from '@/__tests__/factory/playlistFactory'
import playlistFolderFactory from '@/__tests__/factory/playlistFolderFactory'
import userFactory, { states as userStates } from '@/__tests__/factory/userFactory'
import albumTrackFactory from '@/__tests__/factory/albumTrackFactory'
import albumInfoFactory from '@/__tests__/factory/albumInfoFactory'
@ -24,4 +25,5 @@ export default factory
.define('smart-playlist-rule', faker => smartPlaylistRuleFactory(faker))
.define('smart-playlist-rule-group', faker => smartPlaylistRuleGroupFactory(faker))
.define('playlist', faker => playlistFactory(faker), playlistStates)
.define('playlist-folder', faker => playlistFolderFactory(faker))
.define('user', faker => userFactory(faker), userStates)

View file

@ -4,16 +4,20 @@ import { Faker } from '@faker-js/faker'
export default (faker: Faker): Playlist => ({
type: 'playlists',
id: faker.datatype.number(),
folder_id: faker.datatype.uuid(),
name: faker.random.word(),
is_smart: false,
rules: []
})
export const states: Record<string, () => Omit<Partial<Playlist>, 'type'>> = {
smart: faker => ({
export const states: Record<string, (faker: Faker) => Omit<Partial<Playlist>, 'type'>> = {
smart: _ => ({
is_smart: true,
rules: [
factory<SmartPlaylistRule>('smart-playlist-rule')
factory<SmartPlaylistRuleGroup>('smart-playlist-rule-group')
]
}),
orphan: _ => ({
folder_id: null
})
}

View file

@ -0,0 +1,7 @@
import { Faker } from '@faker-js/faker'
export default (faker: Faker): PlaylistFolder => ({
type: 'playlist-folders',
id: faker.datatype.uuid(),
name: faker.random.word()
})

View file

@ -3,6 +3,6 @@ import { Faker } from '@faker-js/faker'
export default (faker: Faker): SmartPlaylistRule => ({
id: faker.datatype.number(),
model: faker.random.arrayElement<SmartPlaylistModel['name']>(['title', 'artist.name', 'album.name']),
operator: faker.random.arrayElement<SmartPlaylistOperator['name']>(['is', 'contains', 'isNot']),
operator: faker.random.arrayElement<SmartPlaylistOperator['operator']>(['is', 'contains', 'isNot']),
value: [faker.random.word()]
})

View file

@ -19,7 +19,7 @@ new class extends UnitTestCase {
})
const rendered = this.render(AlbumContextMenu)
eventBus.emit('ALBUM_CONTEXT_MENU_REQUESTED', { pageX: 420, pageY: 69 }, album)
eventBus.emit('ALBUM_CONTEXT_MENU_REQUESTED', { pageX: 420, pageY: 42 }, album)
await this.tick(2)
return rendered

View file

@ -1,7 +1,7 @@
// Vitest Snapshot v1
exports[`renders 1`] = `
<nav class="album-menu menu context-menu" style="top: 69px; left: 420px;" tabindex="0" data-testid="album-context-menu">
<nav class="album-menu menu context-menu" style="top: 42px; left: 420px;" tabindex="0" data-testid="album-context-menu">
<ul>
<li data-testid="play">Play All</li>
<li data-testid="shuffle">Shuffle All</li>

View file

@ -19,7 +19,7 @@ new class extends UnitTestCase {
})
const rendered = this.render(ArtistContextMenu)
eventBus.emit('ARTIST_CONTEXT_MENU_REQUESTED', { pageX: 420, pageY: 69 }, artist)
eventBus.emit('ARTIST_CONTEXT_MENU_REQUESTED', { pageX: 420, pageY: 42 }, artist)
await this.tick(2)
return rendered

View file

@ -44,6 +44,6 @@ const download = () => trigger(() => downloadService.fromArtist(artist.value))
eventBus.on('ARTIST_CONTEXT_MENU_REQUESTED', async (e: MouseEvent, _artist: Artist) => {
artist.value = _artist
open(e.pageY, e.pageX, { _artist })
await open(e.pageY, e.pageX, { _artist })
})
</script>

View file

@ -1,7 +1,7 @@
// Vitest Snapshot v1
exports[`renders 1`] = `
<nav class="artist-menu menu context-menu" style="top: 69px; left: 420px;" tabindex="0" data-testid="artist-context-menu">
<nav class="artist-menu menu context-menu" style="top: 42px; left: 420px;" tabindex="0" data-testid="artist-context-menu">
<ul>
<li data-testid="play">Play All</li>
<li data-testid="shuffle">Shuffle All</li>

View file

@ -1,25 +1,45 @@
import { it } from 'vitest'
import { waitFor } from '@testing-library/vue'
import factory from '@/__tests__/factory'
import { eventBus } from '@/utils'
import { it } from 'vitest'
import { EventName } from '@/config'
import UnitTestCase from '@/__tests__/UnitTestCase'
import ModalWrapper from './ModalWrapper.vue'
new class extends UnitTestCase {
protected test () {
it.each<[string, EventName, User | Song | Playlist | any]>([
it.each<[string, EventName, User | Song[] | Playlist | PlaylistFolder | undefined]>([
['add-user-form', 'MODAL_SHOW_ADD_USER_FORM', undefined],
['edit-user-form', 'MODAL_SHOW_EDIT_USER_FORM', factory('user')],
['edit-song-form', 'MODAL_SHOW_EDIT_SONG_FORM', [factory('song')]],
['edit-user-form', 'MODAL_SHOW_EDIT_USER_FORM', factory<User>('user')],
['edit-song-form', 'MODAL_SHOW_EDIT_SONG_FORM', [factory<Song>('song')]],
['create-playlist-form', 'MODAL_SHOW_CREATE_PLAYLIST_FORM', undefined],
['create-playlist-folder-form', 'MODAL_SHOW_CREATE_PLAYLIST_FOLDER_FORM', undefined],
['edit-playlist-folder-form', 'MODAL_SHOW_EDIT_PLAYLIST_FOLDER_FORM', factory<PlaylistFolder>('playlist-folder')],
['create-smart-playlist-form', 'MODAL_SHOW_CREATE_SMART_PLAYLIST_FORM', undefined],
['edit-smart-playlist-form', 'MODAL_SHOW_EDIT_SMART_PLAYLIST_FORM', factory('playlist')],
['edit-playlist-form', 'MODAL_SHOW_EDIT_PLAYLIST_FORM', factory<Playlist>('playlist')],
['edit-smart-playlist-form', 'MODAL_SHOW_EDIT_PLAYLIST_FORM', factory<Playlist>('playlist', { is_smart: true })],
['about-koel', 'MODAL_SHOW_ABOUT_KOEL', undefined]
])('shows %s modal', async (modalName: string, eventName: EventName, eventParams?: any) => {
const { findByTestId } = this.render(ModalWrapper)
const { getByTestId } = this.render(ModalWrapper, {
global: {
stubs: {
AddUserForm: this.stub('add-user-form'),
EditUserForm: this.stub('edit-user-form'),
EditSongForm: this.stub('edit-song-form'),
CreatePlaylistForm: this.stub('create-playlist-form'),
CreatePlaylistFolderForm: this.stub('create-playlist-folder-form'),
EditPlaylistFolderForm: this.stub('edit-playlist-folder-form'),
CreateSmartPlaylistForm: this.stub('create-smart-playlist-form'),
EditPlaylistForm: this.stub('edit-playlist-form'),
EditSmartPlaylistForm: this.stub('edit-smart-playlist-form'),
AboutKoel: this.stub('about-koel')
}
}
})
eventBus.emit(eventName, eventParams)
findByTestId(modalName)
await waitFor(() => getByTestId(modalName))
})
}
}

View file

@ -53,7 +53,11 @@ const editSongFormInitialTab = ref<EditSongFormTabName>('details')
provideReadonly(PlaylistKey, playlistToEdit, false)
provideReadonly(UserKey, userToEdit)
provideReadonly(PlaylistFolderKey, playlistFolderToEdit, true, (name: string) => playlistFolderToEdit.value!.name = name)
provideReadonly(PlaylistFolderKey, playlistFolderToEdit, true, (name: string) => {
playlistFolderToEdit.value!.name = name
})
provideReadonly(SongsKey, songsToEdit, false)
provideReadonly(EditSongFormInitialTabKey, editSongFormInitialTab)
@ -66,12 +70,7 @@ eventBus.on({
'MODAL_SHOW_EDIT_PLAYLIST_FORM': (playlist: Playlist) => {
playlistToEdit.value = playlist
showingModalName.value = 'edit-playlist-form'
},
'MODAL_SHOW_EDIT_SMART_PLAYLIST_FORM': (playlist: Playlist) => {
playlistToEdit.value = playlist
showingModalName.value = 'edit-smart-playlist-form'
showingModalName.value = playlist.is_smart ? 'edit-smart-playlist-form' : 'edit-playlist-form'
},
'MODAL_SHOW_EDIT_USER_FORM': (user: User) => {

View file

@ -1,20 +1,20 @@
// Vitest Snapshot v1
exports[`renders with a song 1`] = `
<div class="middle-pane" data-testid="footer-middle-pane">
<div id="progressPane" class="progress">
<h3 class="title">Fahrstuhl to Heaven</h3>
<p class="meta"><a href="/#!/artist/3" class="artist">Led Zeppelin</a> <a href="/#!/album/4" class="album">Led Zeppelin IV</a></p>
<div class="plyr"><audio controls="" crossorigin="anonymous"></audio></div>
<div class="middle-pane" data-testid="footer-middle-pane" data-v-2ff4ca72="">
<div id="progressPane" class="progress" data-v-2ff4ca72="">
<h3 class="title" data-v-2ff4ca72="">Fahrstuhl to Heaven</h3>
<p class="meta" data-v-2ff4ca72=""><a href="/#!/artist/3" class="artist" data-v-2ff4ca72="">Led Zeppelin</a> <a href="/#!/album/4" class="album" data-v-2ff4ca72="">Led Zeppelin IV</a></p>
<div class="plyr" data-v-2ff4ca72=""><audio controls="" crossorigin="anonymous" data-v-2ff4ca72=""></audio></div>
</div>
</div>
`;
exports[`renders without a song 1`] = `
<div class="middle-pane" data-testid="footer-middle-pane">
<div id="progressPane" class="progress">
<div class="middle-pane" data-testid="footer-middle-pane" data-v-2ff4ca72="">
<div id="progressPane" class="progress" data-v-2ff4ca72="">
<!--v-if-->
<div class="plyr"><audio controls="" crossorigin="anonymous"></audio></div>
<div class="plyr" data-v-2ff4ca72=""><audio controls="" crossorigin="anonymous" data-v-2ff4ca72=""></audio></div>
</div>
</div>
`;

View file

@ -105,13 +105,13 @@ const onQueueDragOver = (event: DragEvent) => {
event.preventDefault()
event.dataTransfer!.dropEffect = 'move'
queueMenuItemEl.value!.classList.add('droppable')
queueMenuItemEl.value?.classList.add('droppable')
}
const onQueueDragLeave = () => queueMenuItemEl.value!.classList.remove('droppable')
const onQueueDragLeave = () => queueMenuItemEl.value?.classList.remove('droppable')
const onQueueDrop = async (event: DragEvent) => {
queueMenuItemEl.value!.classList.remove('droppable')
queueMenuItemEl.value?.classList.remove('droppable')
if (!acceptsDrop(event)) return false

View file

@ -0,0 +1,28 @@
import { expect, it } from 'vitest'
import { fireEvent, waitFor } from '@testing-library/vue'
import UnitTestCase from '@/__tests__/UnitTestCase'
import { eventBus } from '@/utils'
import { EventName } from '@/config'
import CreateNewPlaylistContextMenu from './CreateNewPlaylistContextMenu.vue'
new class extends UnitTestCase {
private async renderComponent () {
const rendered = await this.render(CreateNewPlaylistContextMenu)
eventBus.emit('CREATE_NEW_PLAYLIST_CONTEXT_MENU_REQUESTED', { pageX: 420, pageY: 42 })
await this.tick(2)
return rendered
}
protected test () {
it.each<[string, EventName]>([
['playlist-context-menu-create-simple', 'MODAL_SHOW_CREATE_PLAYLIST_FORM'],
['playlist-context-menu-create-smart', 'MODAL_SHOW_CREATE_SMART_PLAYLIST_FORM'],
['playlist-context-menu-create-folder', 'MODAL_SHOW_CREATE_PLAYLIST_FOLDER_FORM']
])('when clicking on %s, should emit %s', async (id, eventName) => {
const { getByTestId } = await this.renderComponent()
const emitMock = this.mock(eventBus, 'emit')
await fireEvent.click(getByTestId(id))
await waitFor(() => expect(emitMock).toHaveBeenCalledWith(eventName))
})
}
}

View file

@ -1,5 +1,5 @@
<template>
<ContextMenuBase ref="base" extra-class="playlist-menu">
<ContextMenuBase ref="base">
<li data-testid="playlist-context-menu-create-simple" @click="onItemClicked('new-playlist')">New Playlist</li>
<li data-testid="playlist-context-menu-create-smart" @click="onItemClicked('new-smart-playlist')">
New Smart Playlist
@ -12,6 +12,7 @@
import { useContextMenu } from '@/composables'
import { eventBus } from '@/utils'
import { EventName } from '@/config'
import { onMounted } from 'vue'
const { base, ContextMenuBase, open, trigger } = useContextMenu()
@ -25,5 +26,9 @@ const actionToEventMap: Record<string, EventName> = {
const onItemClicked = (key: keyof typeof actionToEventMap) => trigger(() => eventBus.emit(actionToEventMap[key]))
defineExpose({ open })
onMounted(() => {
eventBus.on('CREATE_NEW_PLAYLIST_CONTEXT_MENU_REQUESTED', async (e: MouseEvent) => {
await open(e.pageY, e.pageX)
})
})
</script>

View file

@ -0,0 +1,22 @@
import { expect, it } from 'vitest'
import { fireEvent } from '@testing-library/vue'
import UnitTestCase from '@/__tests__/UnitTestCase'
import { playlistFolderStore } from '@/stores'
import factory from '@/__tests__/factory'
import CreatePlaylistFolderForm from './CreatePlaylistFolderForm.vue'
new class extends UnitTestCase {
protected test () {
it('submits', async () => {
const storeMock = this.mock(playlistFolderStore, 'store')
.mockResolvedValue(factory<PlaylistFolder>('playlist-folder'))
const { getByPlaceholderText, getByRole } = await this.render(CreatePlaylistFolderForm)
await fireEvent.update(getByPlaceholderText('Folder name'), 'My folder')
await fireEvent.click(getByRole('button', { name: 'Save' }))
expect(storeMock).toHaveBeenCalledWith('My folder')
})
}
}

View file

@ -1,5 +1,5 @@
<template>
<div @keydown.esc="maybeClose">
<div tabindex="0" @keydown.esc="maybeClose">
<SoundBars v-if="loading"/>
<form v-else @submit.prevent="submit">
<header>
@ -20,8 +20,8 @@
</main>
<footer>
<Btn class="btn-add" type="submit">Save</Btn>
<Btn class="btn-cancel" white @click.prevent="maybeClose">Cancel</Btn>
<Btn type="submit">Save</Btn>
<Btn white @click.prevent="maybeClose">Cancel</Btn>
</footer>
</form>
</div>

View file

@ -0,0 +1,20 @@
import { expect, it } from 'vitest'
import { fireEvent } from '@testing-library/vue'
import UnitTestCase from '@/__tests__/UnitTestCase'
import { playlistStore } from '@/stores'
import factory from '@/__tests__/factory'
import CreatePlaylistForm from './CreatePlaylistForm.vue'
new class extends UnitTestCase {
protected test () {
it('submits', async () => {
const storeMock = this.mock(playlistStore, 'store').mockResolvedValue(factory<PlaylistFolder>('playlist'))
const { getByPlaceholderText, getByRole } = await this.render(CreatePlaylistForm)
await fireEvent.update(getByPlaceholderText('Playlist name'), 'My playlist')
await fireEvent.click(getByRole('button', { name: 'Save' }))
expect(storeMock).toHaveBeenCalledWith('My playlist')
})
}
}

View file

@ -20,8 +20,8 @@
</main>
<footer>
<Btn class="btn-add" type="submit">Save</Btn>
<Btn class="btn-cancel" white @click.prevent="maybeClose">Cancel</Btn>
<Btn type="submit">Save</Btn>
<Btn white @click.prevent="maybeClose">Cancel</Btn>
</footer>
</form>
</div>

View file

@ -0,0 +1,33 @@
import { ref } from 'vue'
import { fireEvent, waitFor } from '@testing-library/vue'
import { expect, it, vi } from 'vitest'
import factory from '@/__tests__/factory'
import UnitTestCase from '@/__tests__/UnitTestCase'
import { playlistFolderStore } from '@/stores'
import { PlaylistFolderKey } from '@/symbols'
import EditPlaylistFolderForm from './EditPlaylistFolderForm.vue'
new class extends UnitTestCase {
protected test () {
it('submits', async () => {
const folder = factory<PlaylistFolder>('playlist-folder', { name: 'My folder' })
const updateFolderNameMock = vi.fn()
const renameMock = this.mock(playlistFolderStore, 'rename')
const { getByPlaceholderText, getByRole } = this.render(EditPlaylistFolderForm, {
global: {
provide: {
[<symbol>PlaylistFolderKey]: [ref(folder), updateFolderNameMock]
}
}
})
await fireEvent.update(getByPlaceholderText('Folder name'), 'Your folder')
await fireEvent.click(getByRole('button', { name: 'Save' }))
await waitFor(() => {
expect(renameMock).toHaveBeenCalledWith(folder, 'Your folder')
expect(updateFolderNameMock).toHaveBeenCalledWith('Your folder')
})
})
}
}

View file

@ -0,0 +1,33 @@
import { expect, it, vi } from 'vitest'
import factory from '@/__tests__/factory'
import UnitTestCase from '@/__tests__/UnitTestCase'
import { playlistStore } from '@/stores'
import { PlaylistKey } from '@/symbols'
import { ref } from 'vue'
import { fireEvent, waitFor } from '@testing-library/vue'
import EditPlaylistForm from './EditPlaylistForm.vue'
new class extends UnitTestCase {
protected test () {
it('submits', async () => {
const playlist = factory<Playlist>('playlist', { name: 'My playlist' })
const updatePlaylistNameMock = vi.fn()
const updateMock = this.mock(playlistStore, 'update')
const { getByPlaceholderText, getByRole } = this.render(EditPlaylistForm, {
global: {
provide: {
[<symbol>PlaylistKey]: [ref(playlist), updatePlaylistNameMock]
}
}
})
await fireEvent.update(getByPlaceholderText('Playlist name'), 'Your playlist')
await fireEvent.click(getByRole('button', { name: 'Save' }))
await waitFor(() => {
expect(updateMock).toHaveBeenCalledWith(playlist, { name: 'Your playlist' })
expect(updatePlaylistNameMock).toHaveBeenCalledWith('Your playlist')
})
})
}
}

View file

@ -0,0 +1,47 @@
import { expect, it } from 'vitest'
import UnitTestCase from '@/__tests__/UnitTestCase'
import { eventBus } from '@/utils'
import factory from '@/__tests__/factory'
import { fireEvent } from '@testing-library/vue'
import PlaylistContextMenu from './PlaylistContextMenu.vue'
new class extends UnitTestCase {
private async renderComponent (playlist: Playlist) {
const rendered = await this.render(PlaylistContextMenu)
eventBus.emit('PLAYLIST_CONTEXT_MENU_REQUESTED', { pageX: 420, pageY: 42 }, playlist)
await this.tick(2)
return rendered
}
protected test () {
it('renames a standard playlist', async () => {
const playlist = factory<Playlist>('playlist')
const { getByText } = await this.renderComponent(playlist)
const emitMock = this.mock(eventBus, 'emit')
await fireEvent.click(getByText('Rename'))
expect(emitMock).toHaveBeenCalledWith('MODAL_SHOW_EDIT_PLAYLIST_FORM', playlist)
})
it('edits a smart playlist', async () => {
const playlist = factory.states('smart')<Playlist>('playlist')
const { getByText } = await this.renderComponent(playlist)
const emitMock = this.mock(eventBus, 'emit')
await fireEvent.click(getByText('Edit'))
expect(emitMock).toHaveBeenCalledWith('MODAL_SHOW_EDIT_PLAYLIST_FORM', playlist)
})
it('deletes a playlist', async () => {
const playlist = factory<Playlist>('playlist')
const { getByText } = await this.renderComponent(playlist)
const emitMock = this.mock(eventBus, 'emit')
await fireEvent.click(getByText('Delete'))
expect(emitMock).toHaveBeenCalledWith('PLAYLIST_DELETE', playlist)
})
}
}

View file

@ -1,5 +1,5 @@
<template>
<ContextMenuBase ref="base" extra-class="playlist-item-menu">
<ContextMenuBase ref="base">
<li :data-testid="`playlist-context-menu-edit-${playlist.id}`" @click="editPlaylist">
{{ playlist.is_smart ? 'Edit' : 'Rename' }}
</li>
@ -8,19 +8,20 @@
</template>
<script lang="ts" setup>
import { Ref, toRef } from 'vue'
import { onMounted, ref } from 'vue'
import { eventBus } from '@/utils'
import { useContextMenu } from '@/composables'
const { context, base, ContextMenuBase, open, trigger } = useContextMenu()
const playlist = toRef(context, 'playlist') as Ref<Playlist>
const editPlaylist = () => trigger(() => eventBus.emit(
playlist.value.is_smart ? 'MODAL_SHOW_EDIT_SMART_PLAYLIST_FORM' : 'MODAL_SHOW_EDIT_PLAYLIST_FORM',
playlist.value
))
const playlist = ref<Playlist>()
const editPlaylist = () => trigger(() => eventBus.emit('MODAL_SHOW_EDIT_PLAYLIST_FORM', playlist.value))
const deletePlaylist = () => trigger(() => eventBus.emit('PLAYLIST_DELETE', playlist.value))
defineExpose({ open })
onMounted(() => {
eventBus.on('PLAYLIST_CONTEXT_MENU_REQUESTED', async (event: MouseEvent, _playlist: Playlist) => {
playlist.value = _playlist
await open(event.pageY, event.pageX, { playlist })
})
})
</script>

View file

@ -0,0 +1,88 @@
import { expect, it } from 'vitest'
import UnitTestCase from '@/__tests__/UnitTestCase'
import { eventBus } from '@/utils'
import factory from '@/__tests__/factory'
import { fireEvent, waitFor } from '@testing-library/vue'
import { playlistStore, songStore } from '@/stores'
import { playbackService } from '@/services'
import router from '@/router'
import PlaylistFolderContextMenu from './PlaylistFolderContextMenu.vue'
new class extends UnitTestCase {
private async renderComponent (folder: PlaylistFolder) {
const rendered = await this.render(PlaylistFolderContextMenu)
eventBus.emit('PLAYLIST_FOLDER_CONTEXT_MENU_REQUESTED', { pageX: 420, pageY: 42 }, folder)
await this.tick(2)
return rendered
}
protected test () {
it('renames', async () => {
const folder = factory<PlaylistFolder>('playlist-folder')
const { getByText } = await this.renderComponent(folder)
const emitMock = this.mock(eventBus, 'emit')
await fireEvent.click(getByText('Rename'))
expect(emitMock).toHaveBeenCalledWith('MODAL_SHOW_EDIT_PLAYLIST_FOLDER_FORM', folder)
})
it('deletes', async () => {
const folder = factory<PlaylistFolder>('playlist-folder')
const { getByText } = await this.renderComponent(folder)
const emitMock = this.mock(eventBus, 'emit')
await fireEvent.click(getByText('Delete'))
expect(emitMock).toHaveBeenCalledWith('PLAYLIST_FOLDER_DELETE', folder)
})
it('plays', async () => {
const folder = this.createPlayableFolder()
const songs = factory<Song>('song', 3)
const fetchMock = this.mock(songStore, 'fetchForPlaylistFolder').mockResolvedValue(songs)
const queueMock = this.mock(playbackService, 'queueAndPlay')
const goMock = this.mock(router, 'go')
const { getByText } = await this.renderComponent(folder)
await fireEvent.click(getByText('Play All'))
await waitFor(() => {
expect(fetchMock).toHaveBeenCalledWith(folder)
expect(queueMock).toHaveBeenCalledWith(songs)
expect(goMock).toHaveBeenCalledWith('queue')
})
})
it('shuffles', async () => {
const folder = this.createPlayableFolder()
const songs = factory<Song>('song', 3)
const fetchMock = this.mock(songStore, 'fetchForPlaylistFolder').mockResolvedValue(songs)
const queueMock = this.mock(playbackService, 'queueAndPlay')
const goMock = this.mock(router, 'go')
const { getByText } = await this.renderComponent(folder)
await fireEvent.click(getByText('Shuffle All'))
await waitFor(() => {
expect(fetchMock).toHaveBeenCalledWith(folder)
expect(queueMock).toHaveBeenCalledWith(songs, true)
expect(goMock).toHaveBeenCalledWith('queue')
})
})
it('does not show shuffle option if folder is empty', async () => {
const folder = factory<PlaylistFolder>('playlist-folder')
const { queryByText } = await this.renderComponent(folder)
expect(queryByText('Shuffle All')).toBeNull()
expect(queryByText('Play All')).toBeNull()
})
}
private createPlayableFolder () {
const folder = factory<PlaylistFolder>('playlist-folder')
this.mock(playlistStore, 'byFolder', factory<Playlist>('playlist', 3, { folder_id: folder.id }))
return folder
}
}

View file

@ -1,13 +1,13 @@
<template>
<ContextMenuBase ref="base" data-testid="playlist-folder-context-menu" extra-class="playlist-folder-menu">
<ContextMenuBase ref="base">
<template v-if="folder">
<template v-if="playable">
<li data-testid="play" @click="play">Play All</li>
<li data-testid="play" @click="shuffle">Shuffle All</li>
<li @click="play">Play All</li>
<li @click="shuffle">Shuffle All</li>
<li class="separator"/>
</template>
<li data-testid="shuffle" @click="rename">Rename</li>
<li data-testid="shuffle" @click="destroy">Delete</li>
<li @click="rename">Rename</li>
<li @click="destroy">Delete</li>
</template>
</ContextMenuBase>
</template>
@ -16,7 +16,7 @@
import { computed, ref } from 'vue'
import { useContextMenu } from '@/composables'
import { eventBus, requireInjection } from '@/utils'
import { playlistFolderStore, playlistStore, songStore } from '@/stores'
import { playlistStore, songStore } from '@/stores'
import { playbackService } from '@/services'
import { DialogBoxKey } from '@/symbols'
import router from '@/router'
@ -39,11 +39,8 @@ const shuffle = () => trigger(async () => {
router.go('queue')
})
const rename = () => trigger(() => eventBus.emit('MODAL_SHOW_EDIT_PLAYLIST_FOLDER_FORM', folder.value!))
const destroy = () => trigger(async () => {
await dialog.value.confirm('Delete this folder?') && await playlistFolderStore.delete(folder.value!)
})
const rename = () => trigger(() => eventBus.emit('MODAL_SHOW_EDIT_PLAYLIST_FOLDER_FORM', folder.value))
const destroy = () => trigger(() => eventBus.emit('PLAYLIST_FOLDER_DELETE', folder.value))
eventBus.on('PLAYLIST_FOLDER_CONTEXT_MENU_REQUESTED', async (e: MouseEvent, _folder: PlaylistFolder) => {
folder.value = _folder

View file

@ -11,14 +11,9 @@
<icon :icon="opened ? faFolderOpen : faFolder" fixed-width/>
{{ folder.name }}
</a>
<ul v-if="playlistsInFolder.length" v-show="opened">
<PlaylistSidebarItem
class="sub-item"
v-for="playlist in playlistsInFolder"
:key="playlist.id"
:playlist="playlist"
type="playlist"
/>
<PlaylistSidebarItem v-for="playlist in playlistsInFolder" :key="playlist.id" :list="playlist" class="sub-item"/>
</ul>
<div
@ -59,37 +54,37 @@ const onDragOver = (event: DragEvent) => {
event.preventDefault()
event.dataTransfer!.dropEffect = 'move'
el.value!.classList.add('droppable')
el.value?.classList.add('droppable')
opened.value = true
}
const onDragLeave = () => el.value!.classList.remove('droppable')
const onDragLeave = () => el.value?.classList.remove('droppable')
const onDrop = async (event: DragEvent) => {
if (!acceptsDrop(event)) return false
event.preventDefault()
el.value!.classList.remove('droppable')
el.value?.classList.remove('droppable')
const playlist = await resolveDroppedValue<Playlist>(event)
if (!playlist || playlist.folder_id === folder.value.id) return
await playlistFolderStore.addPlaylistToFolder(folder.value, playlist)
}
const onDragLeaveHatch = () => hatch.value!.classList.remove('droppable')
const onDragLeaveHatch = () => hatch.value?.classList.remove('droppable')
const onDragOverHatch = (event: DragEvent) => {
if (!acceptsDrop(event)) return false
event.preventDefault()
event.dataTransfer!.dropEffect = 'move'
hatch.value!.classList.add('droppable')
hatch.value?.classList.add('droppable')
}
const onDropOnHatch = async (event: DragEvent) => {
hatch.value!.classList.remove('droppable')
el.value!.classList.remove('droppable')
hatch.value?.classList.remove('droppable')
el.value?.classList.remove('droppable')
const playlist = (await resolveDroppedValue<Playlist>(event))!
// if the playlist isn't in the folder, don't do anything. The folder will handle the drop.

View file

@ -1,57 +1,43 @@
import factory from '@/__tests__/factory'
import { expect, it } from 'vitest'
import { fireEvent } from '@testing-library/vue'
import { eventBus } from '@/utils'
import factory from '@/__tests__/factory'
import UnitTestCase from '@/__tests__/UnitTestCase'
import PlaylistSidebarItem from './PlaylistSidebarItem.vue'
new class extends UnitTestCase {
renderComponent (playlist: Record<string, any>, type: PlaylistType = 'playlist') {
renderComponent (list: PlaylistLike) {
return this.render(PlaylistSidebarItem, {
props: {
playlist,
type
list
}
})
}
protected test () {
it('edits the name of a standard playlist', async () => {
const { getByTestId, queryByTestId } = this.renderComponent(factory<Playlist>('playlist', {
id: 99,
name: 'A Standard Playlist'
}))
it('requests context menu if is playlist', async () => {
const emitMock = this.mock(eventBus, 'emit')
const playlist = factory<Playlist>('playlist')
const { getByTestId } = this.renderComponent(playlist)
expect(await queryByTestId('name-editor')).toBeNull()
await fireEvent.contextMenu(getByTestId('playlist-sidebar-item'))
await fireEvent.dblClick(getByTestId('playlist-sidebar-item'))
getByTestId('name-editor')
expect(emitMock).toHaveBeenCalledWith('PLAYLIST_CONTEXT_MENU_REQUESTED', expect.anything(), playlist)
})
it('does not allow editing the name of the "Favorites" playlist', async () => {
const { getByTestId, queryByTestId } = this.renderComponent({
name: 'Favorites',
it.each<FavoriteList['name'] | RecentlyPlayedList['name']>(['Favorites', 'Recently Played'])
('does not request context menu if not playlist', async (name) => {
const list: FavoriteList | RecentlyPlayedList = {
name,
songs: []
}, 'favorites')
}
expect(await queryByTestId('name-editor')).toBeNull()
const emitMock = this.mock(eventBus, 'emit')
const { getByTestId } = this.renderComponent(list)
await fireEvent.dblClick(getByTestId('playlist-sidebar-item'))
await fireEvent.contextMenu(getByTestId('playlist-sidebar-item'))
expect(await queryByTestId('name-editor')).toBeNull()
})
it('does not allow editing the name of the "Recently Played" playlist', async () => {
const { getByTestId, queryByTestId } = this.renderComponent({
name: 'Recently Played',
songs: []
}, 'recently-played')
expect(await queryByTestId('name-editor')).toBeNull()
await fireEvent.dblClick(getByTestId('playlist-sidebar-item'))
expect(await queryByTestId('name-editor')).toBeNull()
expect(emitMock).not.toHaveBeenCalledWith('PLAYLIST_CONTEXT_MENU_REQUESTED', list)
})
}
}

View file

@ -1,91 +1,77 @@
<template>
<li
ref="el"
:class="['playlist', type, playlist.is_smart ? 'smart' : '']"
class="playlist"
data-testid="playlist-sidebar-item"
draggable="true"
@contextmenu="onContextMenu"
@dragleave="onDragLeave"
@dragover="onDragOver"
@dragstart="onDragStart"
@drop="onDrop"
>
<a :class="{ active }" :href="url" @contextmenu.prevent="onContextMenu">
<icon v-if="type === 'recently-played'" :icon="faClockRotateLeft" class="text-green" fixed-width/>
<icon v-else-if="type === 'favorites'" :icon="faHeart" class="text-maroon" fixed-width/>
<a :class="{ active }" :href="url">
<icon v-if="isRecentlyPlayedList(list)" :icon="faClockRotateLeft" class="text-green" fixed-width/>
<icon v-else-if="isFavoriteList(list)" :icon="faHeart" class="text-maroon" fixed-width/>
<icon
v-else-if="playlist.is_smart"
v-else-if="list.is_smart"
:icon="faBoltLightning"
:mask="faFile"
fixed-width
transform="shrink-7 down-2"
/>
<icon v-else :icon="faMusic" :mask="faFile" fixed-width transform="shrink-7 down-2"/>
{{ playlist.name }}
{{ list.name }}
</a>
<ContextMenu v-if="hasContextMenu" ref="contextMenu" :playlist="playlist"/>
</li>
</template>
<script lang="ts" setup>
import { faBoltLightning, faClockRotateLeft, faFile, faHeart, faMusic } from '@fortawesome/free-solid-svg-icons'
import { computed, nextTick, ref, toRefs } from 'vue'
import { computed, ref, toRefs } from 'vue'
import { eventBus, pluralize, requireInjection } from '@/utils'
import { favoriteStore, playlistStore } from '@/stores'
import router from '@/router'
import { MessageToasterKey } from '@/symbols'
import { useDraggable, useDroppable } from '@/composables'
import ContextMenu from '@/components/playlist/PlaylistContextMenu.vue'
const { startDragging } = useDraggable('playlist')
const { acceptsDrop, resolveDroppedSongs } = useDroppable(['songs', 'album', 'artist'])
const toaster = requireInjection(MessageToasterKey)
const contextMenu = ref<InstanceType<typeof ContextMenu>>()
const el = ref<HTMLLIElement>()
const props = withDefaults(defineProps<{ playlist: Playlist, type?: PlaylistType }>(), { type: 'playlist' })
const { playlist, type } = toRefs(props)
const props = defineProps<{ list: PlaylistLike }>()
const { list } = toRefs(props)
const isPlaylist = (list: PlaylistLike): list is Playlist => 'id' in list
const isFavoriteList = (list: PlaylistLike): list is FavoriteList => list.name === 'Favorites'
const isRecentlyPlayedList = (list: PlaylistLike): list is RecentlyPlayedList => list.name === 'Recently Played'
const active = ref(false)
const url = computed(() => {
switch (type.value) {
case 'playlist':
return `#!/playlist/${playlist.value.id}`
if (isPlaylist(list.value)) return `#!/playlist/${list.value.id}`
if (isFavoriteList(list.value)) return '#!/favorites'
if (isRecentlyPlayedList(list.value)) return '#!/recently-played'
case 'favorites':
return '#!/favorites'
case 'recently-played':
return '#!/recently-played'
default:
throw new Error('Invalid playlist type')
}
throw new Error('Invalid playlist-like type.')
})
const hasContextMenu = computed(() => type.value === 'playlist')
const contentEditable = computed(() => {
if (playlist.value.is_smart) return false
return type.value === 'playlist' || type.value === 'favorites'
if (isRecentlyPlayedList(list.value)) return false
if (isFavoriteList(list.value)) return true
return !list.value.is_smart
})
const onContextMenu = async (event: MouseEvent) => {
if (hasContextMenu.value) {
await nextTick()
router.go(`/playlist/${playlist.value.id}`)
contextMenu.value?.open(event.pageY, event.pageX, { playlist })
const onContextMenu = (event: MouseEvent) => {
if (isPlaylist(list.value)) {
event.preventDefault()
eventBus.emit('PLAYLIST_CONTEXT_MENU_REQUESTED', event, list.value)
}
}
const onDragStart = (event: DragEvent) => {
if (type.value === 'playlist') {
startDragging(event, playlist.value)
}
}
const onDragStart = (event: DragEvent) => isPlaylist(list.value) && startDragging(event, list.value)
const onDragOver = (event: DragEvent) => {
if (!contentEditable.value) return false
@ -93,15 +79,15 @@ const onDragOver = (event: DragEvent) => {
event.preventDefault()
event.dataTransfer!.dropEffect = 'copy'
el.value!.classList.add('droppable')
el.value?.classList.add('droppable')
return false
}
const onDragLeave = () => el.value!.classList.remove('droppable')
const onDragLeave = () => el.value?.classList.remove('droppable')
const onDrop = async (event: DragEvent) => {
el.value!.classList.remove('droppable')
el.value?.classList.remove('droppable')
if (!contentEditable.value) return false
if (!acceptsDrop(event)) return false
@ -110,28 +96,28 @@ const onDrop = async (event: DragEvent) => {
if (!songs?.length) return false
if (type.value === 'favorites') {
if (isFavoriteList(list.value)) {
await favoriteStore.like(songs)
} else if (type.value === 'playlist') {
await playlistStore.addSongs(playlist.value, songs)
toaster.value.success(`Added ${pluralize(songs, 'song')} into "${playlist.value.name}."`)
} else if (isPlaylist(list.value)) {
await playlistStore.addSongs(list.value, songs)
toaster.value.success(`Added ${pluralize(songs, 'song')} into "${list.value.name}."`)
}
return false
}
eventBus.on('LOAD_MAIN_CONTENT', (view: MainViewName, _playlist: Playlist): void => {
eventBus.on('LOAD_MAIN_CONTENT', (view: MainViewName, _list: PlaylistLike): void => {
switch (view) {
case 'Favorites':
active.value = type.value === 'favorites'
active.value = isFavoriteList(list.value)
break
case 'RecentlyPlayed':
active.value = type.value === 'recently-played'
active.value = isRecentlyPlayedList(list.value)
break
case 'Playlist':
active.value = playlist.value === _playlist
active.value = list.value === _list
break
default:

View file

@ -1,30 +1,44 @@
import { it } from 'vitest'
import { playlistStore } from '@/stores'
import { playlistFolderStore, playlistStore } from '@/stores'
import factory from '@/__tests__/factory'
import UnitTestCase from '@/__tests__/UnitTestCase'
import PlaylistSidebarList from './PlaylistSidebarList.vue'
import PlaylistSidebarItem from './PlaylistSidebarItem.vue'
import UnitTestCase from '@/__tests__/UnitTestCase'
import PlaylistFolderSidebarItem from './PlaylistFolderSidebarItem.vue'
new class extends UnitTestCase {
private renderComponent () {
return this.render(PlaylistSidebarList, {
global: {
stubs: {
PlaylistSidebarItem,
PlaylistFolderSidebarItem
}
}
})
}
protected test () {
it('renders all playlists', () => {
it('displays orphan playlists', () => {
playlistStore.state.playlists = [
factory<Playlist>('playlist', { name: 'Foo Playlist' }),
factory<Playlist>('playlist', { name: 'Bar Playlist' }),
factory<Playlist>('playlist', { name: 'Smart Playlist', is_smart: true })
factory.states('orphan')<Playlist>('playlist', { name: 'Foo Playlist' }),
factory.states('orphan')<Playlist>('playlist', { name: 'Bar Playlist' }),
factory.states('smart', 'orphan')<Playlist>('playlist', { name: 'Smart Playlist' })
]
const { getByText } = this.render(PlaylistSidebarList, {
global: {
stubs: {
PlaylistSidebarItem
}
}
})
const { getByText } = this.renderComponent()
;['Favorites', 'Recently Played', 'Foo Playlist', 'Bar Playlist', 'Smart Playlist'].forEach(t => getByText(t))
})
// other functionalities are handled by E2E
it('displays playlist folders', () => {
playlistFolderStore.state.folders = [
factory<PlaylistFolder>('playlist-folder', { name: 'Foo Folder' }),
factory<PlaylistFolder>('playlist-folder', { name: 'Bar Folder' })
]
const { getByText } = this.renderComponent()
;['Foo Folder', 'Bar Folder'].forEach(t => getByText(t))
})
}
}

View file

@ -8,50 +8,38 @@
data-testid="sidebar-create-playlist-btn"
role="button"
title="Create a new playlist or folder"
@click.stop.prevent="toggleContextMenu"
@click.stop.prevent="requestContextMenu"
/>
</h1>
<ul>
<PlaylistSidebarItem :playlist="{ name: 'Favorites', songs: favorites }" type="favorites"/>
<PlaylistSidebarItem :playlist="{ name: 'Recently Played', songs: [] }" type="recently-played"/>
<PlaylistSidebarItem :list="{ name: 'Favorites', songs: favorites }"/>
<PlaylistSidebarItem :list="{ name: 'Recently Played', songs: [] }"/>
<PlaylistFolderSidebarItem v-for="folder in folders" :key="folder.id" :folder="folder"/>
<PlaylistSidebarItem
v-for="playlist in rootLevelPlaylists"
:key="playlist.id"
:playlist="playlist"
type="playlist"
/>
<PlaylistSidebarItem v-for="playlist in orphanPlaylists" :key="playlist.id" :list="playlist"/>
</ul>
<ContextMenu ref="contextMenu"/>
</section>
</template>
<script lang="ts" setup>
import { faCirclePlus } from '@fortawesome/free-solid-svg-icons'
import { computed, nextTick, ref, toRef } from 'vue'
import { computed, toRef } from 'vue'
import { favoriteStore, playlistFolderStore, playlistStore } from '@/stores'
import { requireInjection } from '@/utils'
import { eventBus, requireInjection } from '@/utils'
import { MessageToasterKey } from '@/symbols'
import ContextMenu from '@/components/playlist/CreateNewPlaylistContextMenu.vue'
import PlaylistSidebarItem from '@/components/playlist/PlaylistSidebarItem.vue'
import PlaylistFolderSidebarItem from '@/components/playlist/PlaylistFolderSidebarItem.vue'
const toaster = requireInjection(MessageToasterKey)
const contextMenu = ref<InstanceType<typeof ContextMenu>>()
const folders = toRef(playlistFolderStore.state, 'folders')
const playlists = toRef(playlistStore.state, 'playlists')
const favorites = toRef(favoriteStore.state, 'songs')
const rootLevelPlaylists: Playlist[] = computed(() => playlists.value.filter(playlist => playlist.folder_id === null))
const orphanPlaylists = computed(() => playlists.value.filter(playlist => playlist.folder_id === null))
const toggleContextMenu = async (event: MouseEvent) => {
await nextTick()
contextMenu.value?.open(event.pageY, event.pageX)
}
const requestContextMenu = (event: MouseEvent) => eventBus.emit('CREATE_NEW_PLAYLIST_CONTEXT_MENU_REQUESTED', event)
</script>
<style lang="scss">

View file

@ -50,7 +50,7 @@
<template v-if="playlist?.is_smart">
No songs match the playlist's
<a @click.prevent="editSmartPlaylist">criteria</a>.
<a @click.prevent="editPlaylist">criteria</a>.
</template>
<template v-else>
The playlist is currently empty.
@ -107,14 +107,14 @@ const allowDownload = toRef(commonStore.state, 'allow_download')
const destroy = () => eventBus.emit('PLAYLIST_DELETE', playlist.value)
const download = () => downloadService.fromPlaylist(playlist.value!)
const editSmartPlaylist = () => eventBus.emit('MODAL_SHOW_EDIT_SMART_PLAYLIST_FORM', playlist.value)
const editPlaylist = () => eventBus.emit('MODAL_SHOW_EDIT_PLAYLIST_FORM', playlist.value)
const removeSelected = () => {
if (!selectedSongs.value.length || playlist.value.is_smart) return
if (!selectedSongs.value.length || playlist.value!.is_smart) return
playlistStore.removeSongs(playlist.value!, selectedSongs.value)
songs.value = differenceBy(songs.value, selectedSongs.value, 'id')
toaster.value.success(`Removed ${pluralize(selectedSongs.value.length, 'song')} from "${playlist.value!.name}."`)
toaster.value.success(`Removed ${pluralize(selectedSongs.value, 'song')} from "${playlist.value!.name}."`)
}
const fetchSongs = async () => {

View file

@ -3,15 +3,15 @@
exports[`renders 1`] = `
<section id="albumWrapper" data-v-748fe44c="">
<!--v-if-->
<header class="screen-header expanded" data-v-748fe44c="">
<aside class="thumbnail-wrapper"><span class="cover" data-testid="album-artist-thumbnail" data-v-901ba52c="" data-v-748fe44c=""><a class="control control-play" href="" role="button" data-v-901ba52c=""><span class="hidden" data-v-901ba52c="">Play all songs in the album Led Zeppelin IV</span><span class="icon" data-v-901ba52c=""></span></a></span></aside>
<main>
<div class="heading-wrapper">
<h1 class="name">Led Zeppelin IV
<header class="screen-header expanded" data-v-661f8f0d="" data-v-748fe44c="">
<aside class="thumbnail-wrapper" data-v-661f8f0d=""><span class="cover" data-testid="album-artist-thumbnail" data-v-901ba52c="" data-v-748fe44c=""><a class="control control-play" href="" role="button" data-v-901ba52c=""><span class="hidden" data-v-901ba52c="">Play all songs in the album Led Zeppelin IV</span><span class="icon" data-v-901ba52c=""></span></a></span></aside>
<main data-v-661f8f0d="">
<div class="heading-wrapper" data-v-661f8f0d="">
<h1 class="name" data-v-661f8f0d="">Led Zeppelin IV
<!--v-if-->
</h1><span class="meta text-secondary"><a href="#!/artist/123" class="artist" data-v-748fe44c="">Led Zeppelin</a><span data-v-748fe44c="">10 songs</span><span data-v-748fe44c="">26:43</span><a class="info" href="" title="View album information" data-v-748fe44c="">Info</a><a class="download" href="" role="button" title="Download all songs in album" data-v-748fe44c=""> Download All </a></span>
</h1><span class="meta text-secondary" data-v-661f8f0d=""><a href="#!/artist/123" class="artist" data-v-748fe44c="">Led Zeppelin</a><span data-v-748fe44c="">10 songs</span><span data-v-748fe44c="">26:43</span><a class="info" href="" title="View album information" data-v-748fe44c="">Info</a><a class="download" href="" role="button" title="Download all songs in album" data-v-748fe44c=""> Download All </a></span>
</div>
<div class="song-list-controls" data-testid="song-list-controls" data-v-cee28c08="" data-v-748fe44c=""><span class="btn-group" uppercased="" data-v-cee28c08=""><button type="button" class="btn-shuffle-all" data-testid="btn-shuffle-all" orange="" title="Shuffle all songs" data-v-27deb898="" data-v-cee28c08=""><br data-testid="icon" icon="[object Object]" data-v-cee28c08=""> All </button><!--v-if--><!--v-if--><!--v-if--><!--v-if--></span>
<div class="song-list-controls" data-testid="song-list-controls" data-v-cee28c08="" data-v-748fe44c=""><span class="btn-group" uppercased="" data-v-5d6fa912="" data-v-cee28c08=""><button type="button" class="btn-shuffle-all" data-testid="btn-shuffle-all" orange="" title="Shuffle all songs" data-v-27deb898="" data-v-cee28c08=""><br data-testid="icon" icon="[object Object]" data-v-cee28c08=""> All </button><!--v-if--><!--v-if--><!--v-if--><!--v-if--></span>
<div class="add-to" data-testid="add-to-menu" tabindex="0" data-v-0351ff38="" data-v-cee28c08="" style="display: none;">
<section class="existing-playlists" data-v-0351ff38="">
<p data-v-0351ff38="">Add 0 songs to</p>

View file

@ -159,3 +159,35 @@ exports[`renders 5`] = `
</header><br data-testid="song-list">
</section>
`;
exports[`renders 6`] = `
<section id="songsWrapper">
<header class="screen-header expanded" data-v-661f8f0d="">
<aside class="thumbnail-wrapper" data-v-661f8f0d="">
<div class="thumbnail-stack single" style="background-image: url(undefined/resources/assets/img/covers/default.svg);" data-v-2978c570=""><span data-testid="thumbnail" data-v-2978c570=""></span></div>
</aside>
<main data-v-661f8f0d="">
<div class="heading-wrapper" data-v-661f8f0d="">
<h1 class="name" data-v-661f8f0d=""> All Songs
<!--v-if-->
</h1><span class="meta text-secondary" data-v-661f8f0d=""><span>420 songs</span><span>34:17:36</span></span>
</div>
<div class="song-list-controls" data-testid="song-list-controls" data-v-cee28c08=""><span class="btn-group" uppercased="" data-v-5d6fa912="" data-v-cee28c08=""><button type="button" class="btn-shuffle-all" data-testid="btn-shuffle-all" orange="" title="Shuffle all songs" data-v-27deb898="" data-v-cee28c08=""><br data-testid="icon" icon="[object Object]" data-v-cee28c08=""> All </button><!--v-if--><!--v-if--><!--v-if--><!--v-if--></span>
<div class="add-to" data-testid="add-to-menu" tabindex="0" data-v-0351ff38="" data-v-cee28c08="" style="display: none;">
<section class="existing-playlists" data-v-0351ff38="">
<p data-v-0351ff38="">Add 0 songs to</p>
<ul data-v-0351ff38="">
<li data-testid="queue" tabindex="0" data-v-0351ff38="">Queue</li>
<li class="favorites" data-testid="add-to-favorites" tabindex="0" data-v-0351ff38=""> Favorites </li>
</ul>
</section>
<section class="new-playlist" data-testid="new-playlist" data-v-0351ff38="">
<p data-v-0351ff38="">or create a new playlist</p>
<form class="form-save form-simple form-new-playlist" data-v-0351ff38=""><input data-testid="new-playlist-name" placeholder="Playlist name" required="" type="text" data-v-0351ff38=""><button type="submit" title="Save" data-v-27deb898="" data-v-0351ff38="">⏎</button></form>
</section>
</div>
</div>
</main>
</header><br data-testid="song-list">
</section>
`;

View file

@ -3,15 +3,15 @@
exports[`renders 1`] = `
<section id="artistWrapper" data-v-dceda15c="">
<!--v-if-->
<header class="screen-header expanded" data-v-dceda15c="">
<aside class="thumbnail-wrapper"><span class="cover" data-testid="album-artist-thumbnail" data-v-901ba52c="" data-v-dceda15c=""><a class="control control-play" href="" role="button" data-v-901ba52c=""><span class="hidden" data-v-901ba52c="">Play all songs by Led Zeppelin</span><span class="icon" data-v-901ba52c=""></span></a></span></aside>
<main>
<div class="heading-wrapper">
<h1 class="name">Led Zeppelin
<header class="screen-header expanded" data-v-661f8f0d="" data-v-dceda15c="">
<aside class="thumbnail-wrapper" data-v-661f8f0d=""><span class="cover" data-testid="album-artist-thumbnail" data-v-901ba52c="" data-v-dceda15c=""><a class="control control-play" href="" role="button" data-v-901ba52c=""><span class="hidden" data-v-901ba52c="">Play all songs by Led Zeppelin</span><span class="icon" data-v-901ba52c=""></span></a></span></aside>
<main data-v-661f8f0d="">
<div class="heading-wrapper" data-v-661f8f0d="">
<h1 class="name" data-v-661f8f0d="">Led Zeppelin
<!--v-if-->
</h1><span class="meta text-secondary"><span data-v-dceda15c="">12 albums</span><span data-v-dceda15c="">53 songs</span><span data-v-dceda15c="">11:16:43</span><a class="info" href="" title="View artist information" data-v-dceda15c="">Info</a><a class="download" href="" role="button" title="Download all songs by this artist" data-v-dceda15c=""> Download All </a></span>
</h1><span class="meta text-secondary" data-v-661f8f0d=""><span data-v-dceda15c="">12 albums</span><span data-v-dceda15c="">53 songs</span><span data-v-dceda15c="">11:16:43</span><a class="info" href="" title="View artist information" data-v-dceda15c="">Info</a><a class="download" href="" role="button" title="Download all songs by this artist" data-v-dceda15c=""> Download All </a></span>
</div>
<div class="song-list-controls" data-testid="song-list-controls" data-v-cee28c08="" data-v-dceda15c=""><span class="btn-group" uppercased="" data-v-cee28c08=""><button type="button" class="btn-shuffle-all" data-testid="btn-shuffle-all" orange="" title="Shuffle all songs" data-v-27deb898="" data-v-cee28c08=""><br data-testid="icon" icon="[object Object]" data-v-cee28c08=""> All </button><!--v-if--><!--v-if--><!--v-if--><!--v-if--></span>
<div class="song-list-controls" data-testid="song-list-controls" data-v-cee28c08="" data-v-dceda15c=""><span class="btn-group" uppercased="" data-v-5d6fa912="" data-v-cee28c08=""><button type="button" class="btn-shuffle-all" data-testid="btn-shuffle-all" orange="" title="Shuffle all songs" data-v-27deb898="" data-v-cee28c08=""><br data-testid="icon" icon="[object Object]" data-v-cee28c08=""> All </button><!--v-if--><!--v-if--><!--v-if--><!--v-if--></span>
<div class="add-to" data-testid="add-to-menu" tabindex="0" data-v-0351ff38="" data-v-cee28c08="" style="display: none;">
<section class="existing-playlists" data-v-0351ff38="">
<p data-v-0351ff38="">Add 0 songs to</p>

View file

@ -2,11 +2,11 @@
exports[`renders 1`] = `
<section id="settingsWrapper">
<header class="screen-header expanded">
<aside class="thumbnail-wrapper"></aside>
<main>
<div class="heading-wrapper">
<h1 class="name">Settings</h1><span class="meta text-secondary"></span>
<header class="screen-header expanded" data-v-661f8f0d="">
<aside class="thumbnail-wrapper" data-v-661f8f0d=""></aside>
<main data-v-661f8f0d="">
<div class="heading-wrapper" data-v-661f8f0d="">
<h1 class="name" data-v-661f8f0d="">Settings</h1><span class="meta text-secondary" data-v-661f8f0d=""></span>
</div>
</main>
</header>

View file

@ -18,8 +18,8 @@ new class extends UnitTestCase {
const rendered = this.render(EditSongForm, {
global: {
provide: {
[SongsKey]: [ref(songs)],
[EditSongFormInitialTabKey]: [ref(initialTab)]
[<symbol>SongsKey]: [ref(songs)],
[<symbol>EditSongFormInitialTabKey]: [ref(initialTab)]
}
}
})
@ -70,7 +70,7 @@ new class extends UnitTestCase {
const updateMock = this.mock(songStore, 'update')
const alertMock = this.mock(MessageToasterStub.value, 'success')
const { html, getByTestId, getByRole, queryByTestId } = await this.renderComponent(factory<Song[]>('song', 3))
const { html, getByTestId, getByRole, queryByTestId } = await this.renderComponent(factory<Song>('song', 3))
expect(html()).toMatchSnapshot()
expect(queryByTestId('title-input')).toBeNull()
@ -96,12 +96,12 @@ new class extends UnitTestCase {
})
it('displays artist name if all songs have the same artist', async () => {
const { getByTestId } = await this.renderComponent(factory<Song[]>('song', {
const { getByTestId } = await this.renderComponent(factory<Song>('song', 4, {
artist_id: 1000,
artist_name: 'Led Zeppelin',
album_id: 1001,
album_name: 'IV'
}, 4))
}))
expect(getByTestId('displayed-artist-name').textContent).toBe('Led Zeppelin')
expect(getByTestId('displayed-album-name').textContent).toBe('IV')

View file

@ -17,18 +17,18 @@ new class extends UnitTestCase {
}
private async renderComponent (_songs?: Song | Song[]) {
songs = arrayify(_songs || factory<Song[]>('song', 5))
songs = arrayify(_songs || factory<Song>('song', 5))
const rendered = this.render(SongContextMenu)
eventBus.emit('SONG_CONTEXT_MENU_REQUESTED', { pageX: 420, pageY: 69 }, songs)
eventBus.emit('SONG_CONTEXT_MENU_REQUESTED', { pageX: 420, pageY: 42 }, songs)
await this.tick(2)
return rendered
}
private fillQueue () {
queueStore.state.songs = factory<Song[]>('song', 5)
queueStore.state.current = queueStore.state.songs[0]
queueStore.state.songs = factory<Song>('song', 5)
queueStore.state.songs[2].playback_state = 'Playing'
}
protected test () {
@ -138,7 +138,7 @@ new class extends UnitTestCase {
})
it('lists and adds to existing playlist', async () => {
playlistStore.state.playlists = factory<Playlist[]>('playlist', 3)
playlistStore.state.playlists = factory<Playlist>('playlist', 3)
const addMock = this.mock(playlistStore, 'addSongs')
this.mock(MessageToasterStub.value, 'success')
const { queryByText, getByText } = await this.renderComponent()
@ -151,7 +151,7 @@ new class extends UnitTestCase {
})
it('does not list smart playlists', async () => {
playlistStore.state.playlists = factory<Playlist[]>('playlist', 3)
playlistStore.state.playlists = factory<Playlist>('playlist', 3)
playlistStore.state.playlists.push(factory.states('smart')<Playlist>('playlist', { name: 'My Smart Playlist' }))
const { queryByText } = await this.renderComponent()

View file

@ -67,7 +67,7 @@ const playlists = toRef(playlistStore.state, 'playlists')
const allowDownload = toRef(commonStore.state, 'allow_download')
const user = toRef(userStore.state, 'current')
const queue = toRef(queueStore.state, 'songs')
const currentSong = toRef(queueStore.state, 'current')
const currentSong = queueStore.current
const onlyOneSongSelected = computed(() => songs.value.length === 1)
const firstSongPlaying = computed(() => songs.value.length ? songs.value[0].playback_state === 'Playing' : false)

View file

@ -1,5 +1,6 @@
import { expect, it } from 'vitest'
import { ref } from 'vue'
import { expect, it } from 'vitest'
import { fireEvent } from '@testing-library/vue'
import factory from '@/__tests__/factory'
import UnitTestCase from '@/__tests__/UnitTestCase'
import { arrayify } from '@/utils'
@ -12,7 +13,6 @@ import {
SongsKey
} from '@/symbols'
import SongList from './SongList.vue'
import { fireEvent } from '@testing-library/vue'
let songs: Song[]
@ -27,18 +27,21 @@ new class extends UnitTestCase {
) {
songs = arrayify(_songs)
const sortFieldRef = ref(sortField)
const sortOrderRef = ref(sortOrder)
return this.render(SongList, {
global: {
stubs: {
VirtualScroller: this.stub('virtual-scroller')
},
provide: {
[SongsKey]: [ref(songs)],
[SelectedSongsKey]: [ref(selectedSongs), value => selectedSongs = value],
[SongListTypeKey]: [ref(type)],
[SongListConfigKey]: [config],
[SongListSortFieldKey]: [ref(sortField), value => sortField = value],
[SongListSortOrderKey]: [ref(sortOrder), value => sortOrder = value]
[<symbol>SongsKey]: [ref(songs)],
[<symbol>SelectedSongsKey]: [ref(selectedSongs), value => (selectedSongs = value)],
[<symbol>SongListTypeKey]: [ref(type)],
[<symbol>SongListConfigKey]: [config],
[<symbol>SongListSortFieldKey]: [sortFieldRef, value => (sortFieldRef.value = value)],
[<symbol>SongListSortOrderKey]: [sortOrderRef, value => (sortOrderRef.value = value)]
}
}
})
@ -46,24 +49,24 @@ new class extends UnitTestCase {
protected test () {
it('renders', async () => {
const { html } = this.renderComponent(factory<Song[]>('song', 5))
const { html } = this.renderComponent(factory<Song>('song', 5))
expect(html()).toMatchSnapshot()
})
it.each([
it.each<[SongListSortField, string]>([
['track', 'header-track-number'],
['title', 'header-title'],
['album_name', 'header-album'],
['length', 'header-length'],
['artist_name', 'header-artist']
])('sorts by %s upon %s clicked', async (field: SongListSortField, testId: string) => {
const { getByTestId, emitted } = this.renderComponent(factory<Song[]>('song', 5))
])('sorts by %s upon %s clicked', async (field, testId) => {
const { getByTestId, emitted } = this.renderComponent(factory<Song>('song', 5))
await fireEvent.click(getByTestId(testId))
expect(emitted().sort[0]).toBeTruthy([field, 'asc'])
expect(emitted().sort[0]).toEqual([field, 'desc'])
await fireEvent.click(getByTestId(testId))
expect(emitted().sort[0]).toBeTruthy([field, 'desc'])
expect(emitted().sort[1]).toEqual([field, 'asc'])
})
}
}

View file

@ -0,0 +1,71 @@
// Vitest Snapshot v1
exports[`edits a single song 1`] = `
<div class="edit-song" data-testid="edit-song-form" tabindex="0" data-v-210b4214="">
<form data-v-210b4214="">
<header data-v-210b4214=""><span class="cover" style="background-image: url(https://example.co/album.jpg);" data-v-210b4214=""></span>
<div class="meta" data-v-210b4214="">
<h1 class="" data-v-210b4214="">Rocket to Heaven</h1>
<h2 data-testid="displayed-artist-name" class="" data-v-210b4214="">Led Zeppelin</h2>
<h2 data-testid="displayed-album-name" class="" data-v-210b4214="">IV</h2>
</div>
</header>
<main class="tabs" data-v-210b4214="">
<div class="clear" role="tablist" data-v-210b4214=""><button id="editSongTabDetails" aria-selected="true" aria-controls="editSongPanelDetails" role="tab" type="button" data-v-210b4214=""> Details </button><button id="editSongTabLyrics" aria-selected="false" aria-controls="editSongPanelLyrics" data-testid="edit-song-lyrics-tab" role="tab" type="button" data-v-210b4214=""> Lyrics </button></div>
<div class="panes" data-v-210b4214="">
<div id="editSongPanelDetails" aria-labelledby="editSongTabDetails" role="tabpanel" tabindex="0" data-v-210b4214="">
<div class="form-row" data-v-210b4214=""><label data-v-210b4214=""> Title <input data-testid="title-input" name="title" title="Title" type="text" data-v-210b4214=""></label></div>
<div class="form-row" data-v-210b4214=""><label data-v-210b4214=""> Artist <input placeholder="" data-testid="artist-input" name="artist" type="text" data-v-210b4214=""></label></div>
<div class="form-row" data-v-210b4214=""><label data-v-210b4214=""> Album <input placeholder="" data-testid="album-input" name="album" type="text" data-v-210b4214=""></label></div>
<div class="form-row" data-v-210b4214=""><label data-v-210b4214=""> Album Artist <input placeholder="" data-testid="albumArtist-input" name="album_artist" type="text" data-v-210b4214=""></label></div>
<div class="form-row" data-v-210b4214="">
<div class="cols" data-v-210b4214="">
<div data-v-210b4214=""><label data-v-210b4214=""> Track <input placeholder="" data-testid="track-input" min="1" name="track" type="number" data-v-210b4214=""></label></div>
<div data-v-210b4214=""><label data-v-210b4214=""> Disc <input placeholder="" data-testid="disc-input" min="1" name="disc" type="number" data-v-210b4214=""></label></div>
</div>
</div>
</div>
<div id="editSongPanelLyrics" aria-labelledby="editSongTabLyrics" role="tabpanel" tabindex="0" data-v-210b4214="" style="display: none;">
<div class="form-row" data-v-210b4214=""><textarea data-testid="lyrics-input" name="lyrics" title="Lyrics" data-v-210b4214=""></textarea></div>
</div>
</div>
</main>
<footer data-v-210b4214=""><button type="submit" data-v-27deb898="" data-v-210b4214="">Update</button><button type="button" class="btn-cancel" white="" data-v-27deb898="" data-v-210b4214="">Cancel</button></footer>
</form>
</div>
`;
exports[`edits multiple songs 1`] = `
<div class="edit-song" data-testid="edit-song-form" tabindex="0" data-v-210b4214="">
<form data-v-210b4214="">
<header data-v-210b4214=""><span class="cover" style="background-image: url(undefined/resources/assets/img/covers/default.svg);" data-v-210b4214=""></span>
<div class="meta" data-v-210b4214="">
<h1 class="mixed" data-v-210b4214="">3 songs selected</h1>
<h2 data-testid="displayed-artist-name" class="mixed" data-v-210b4214="">Mixed Artists</h2>
<h2 data-testid="displayed-album-name" class="mixed" data-v-210b4214="">Mixed Albums</h2>
</div>
</header>
<main class="tabs" data-v-210b4214="">
<div class="clear" role="tablist" data-v-210b4214=""><button id="editSongTabDetails" aria-selected="true" aria-controls="editSongPanelDetails" role="tab" type="button" data-v-210b4214=""> Details </button>
<!--v-if-->
</div>
<div class="panes" data-v-210b4214="">
<div id="editSongPanelDetails" aria-labelledby="editSongTabDetails" role="tabpanel" tabindex="0" data-v-210b4214="">
<!--v-if-->
<div class="form-row" data-v-210b4214=""><label data-v-210b4214=""> Artist <input placeholder="Leave unchanged" data-testid="artist-input" name="artist" type="text" data-v-210b4214=""></label></div>
<div class="form-row" data-v-210b4214=""><label data-v-210b4214=""> Album <input placeholder="Leave unchanged" data-testid="album-input" name="album" type="text" data-v-210b4214=""></label></div>
<div class="form-row" data-v-210b4214=""><label data-v-210b4214=""> Album Artist <input placeholder="Leave unchanged" data-testid="albumArtist-input" name="album_artist" type="text" data-v-210b4214=""></label></div>
<div class="form-row" data-v-210b4214="">
<div class="cols" data-v-210b4214="">
<div data-v-210b4214=""><label data-v-210b4214=""> Track <input placeholder="Leave unchanged" data-testid="track-input" min="1" name="track" type="number" data-v-210b4214=""></label></div>
<div data-v-210b4214=""><label data-v-210b4214=""> Disc <input placeholder="Leave unchanged" data-testid="disc-input" min="1" name="disc" type="number" data-v-210b4214=""></label></div>
</div>
</div>
</div>
<!--v-if-->
</div>
</main>
<footer data-v-210b4214=""><button type="submit" data-v-27deb898="" data-v-210b4214="">Update</button><button type="button" class="btn-cancel" white="" data-v-27deb898="" data-v-210b4214="">Cancel</button></footer>
</form>
</div>
`;

View file

@ -1,3 +1,3 @@
// Vitest Snapshot v1
exports[`renders 1`] = `<span class="btn-group"><button type="button" data-v-27deb898="">Green</button><button type="button" data-v-27deb898="">Orange</button><button type="button" data-v-27deb898="">Blue</button></span>`;
exports[`renders 1`] = `<span class="btn-group" data-v-5d6fa912=""><button type="button" data-v-27deb898="">Green</button><button type="button" data-v-27deb898="">Orange</button><button type="button" data-v-27deb898="">Blue</button></span>`;

View file

@ -1,11 +1,11 @@
// Vitest Snapshot v1
exports[`renders 1`] = `
<header class="screen-header expanded">
<aside class="thumbnail-wrapper"><img src="https://placekitten.com/200/300"></aside>
<main>
<div class="heading-wrapper">
<h1 class="name">This Header</h1><span class="meta text-secondary"><p>Some meta</p></span>
<header class="screen-header expanded" data-v-661f8f0d="">
<aside class="thumbnail-wrapper" data-v-661f8f0d=""><img src="https://placekitten.com/200/300"></aside>
<main data-v-661f8f0d="">
<div class="heading-wrapper" data-v-661f8f0d="">
<h1 class="name" data-v-661f8f0d="">This Header</h1><span class="meta text-secondary" data-v-661f8f0d=""><p>Some meta</p></span>
</div>
<nav>Some controls</nav>
</main>

View file

@ -19,7 +19,7 @@ new class extends UnitTestCase {
const { getByLabelText, getByRole } = this.render(EditUserForm, {
global: {
provide: {
[UserKey]: [user]
[<symbol>UserKey]: [user]
}
}
})

View file

@ -9,7 +9,7 @@
import isMobile from 'ismobilejs'
import router from '@/router'
import { authService } from '@/services'
import { playlistStore, preferenceStore, userStore } from '@/stores'
import { playlistFolderStore, playlistStore, preferenceStore, userStore } from '@/stores'
import { eventBus, forceReloadWindow, requireInjection } from '@/utils'
import { DialogBoxKey, MessageToasterKey } from '@/symbols'
@ -18,13 +18,21 @@ const dialog = requireInjection(DialogBoxKey)
eventBus.on({
'PLAYLIST_DELETE': async (playlist: Playlist) => {
if (await dialog.value.confirm(`Are you sure you want to delete "${playlist.name}"?`, 'Delete Playlist')) {
if (await dialog.value.confirm(`Delete the playlist "${playlist.name}"?`)) {
await playlistStore.delete(playlist)
toaster.value.success(`Playlist "${playlist.name}" deleted.`)
router.go('home')
}
},
'PLAYLIST_FOLDER_DELETE': async (folder: PlaylistFolder) => {
if (await dialog.value.confirm(`Delete the playlist folder "${folder.name}"?`)) {
await playlistFolderStore.delete(folder)
toaster.value.success(`Playlist folder "${folder.name}" deleted.`)
router.go('home')
}
},
/**
* Log the current user out and reset the application state.
*/

View file

@ -10,22 +10,27 @@ export type EventName =
| 'INIT_EQUALIZER'
| 'TOGGLE_VISUALIZER'
| 'SEARCH_KEYWORDS_CHANGED'
| 'SONG_CONTEXT_MENU_REQUESTED'
| 'ALBUM_CONTEXT_MENU_REQUESTED'
| 'ARTIST_CONTEXT_MENU_REQUESTED'
| 'CREATE_NEW_PLAYLIST_CONTEXT_MENU_REQUESTED'
| 'PLAYLIST_CONTEXT_MENU_REQUESTED'
| 'PLAYLIST_FOLDER_CONTEXT_MENU_REQUESTED'
| 'CONTEXT_MENU_OPENED'
| 'MODAL_SHOW_ADD_USER_FORM'
| 'MODAL_SHOW_EDIT_USER_FORM'
| 'MODAL_SHOW_EDIT_SONG_FORM'
| 'MODAL_SHOW_CREATE_PLAYLIST_FORM'
| 'MODAL_SHOW_EDIT_PLAYLIST_FORM'
| 'MODAL_SHOW_CREATE_SMART_PLAYLIST_FORM'
| 'MODAL_SHOW_EDIT_SMART_PLAYLIST_FORM'
| 'MODAL_SHOW_CREATE_PLAYLIST_FOLDER_FORM'
| 'MODAL_SHOW_EDIT_PLAYLIST_FOLDER_FORM'
| 'MODAL_SHOW_ABOUT_KOEL'
| 'PLAYLIST_DELETE'
| 'PLAYLIST_FOLDER_DELETE'
| 'SMART_PLAYLIST_UPDATED'
| 'SONG_STARTED'
| 'SONGS_UPDATED'

View file

@ -32,8 +32,8 @@ export const playlistFolderStore = {
},
async addPlaylistToFolder (folder: PlaylistFolder, playlist: Playlist) {
// Update the folder ID right away, so that the UI can be updated immediately.
// The actual update will be done in the background.
// Update the folder ID right away, so that the UI can be refreshed immediately.
// The actual HTTP request will be done in the background.
playlist.folder_id = folder.id
await httpService.post(`playlist-folders/${folder.id}/playlists`, { playlists: [playlist.id] })
},

View file

@ -80,14 +80,14 @@ new class extends UnitTestCase {
})
it('stores a playlist', async () => {
const songs = factory<Song[]>('song', 3)
const songs = factory<Song>('song', 3)
const playlist = factory<Playlist>('playlist')
const postMock = this.mock(httpService, 'post').mockResolvedValue(playlist)
const serializeMock = this.mock(playlistStore, 'serializeSmartPlaylistRulesForStorage', null)
await playlistStore.store('New Playlist', songs, [])
expect(postMock).toHaveBeenCalledWith('playlist', {
expect(postMock).toHaveBeenCalledWith('playlists', {
name: 'New Playlist',
songs: songs.map(song => song.id),
rules: null
@ -112,7 +112,7 @@ new class extends UnitTestCase {
it('adds songs to a playlist', async () => {
const playlist = factory<Playlist>('playlist', { id: 12 })
const songs = factory<Song[]>('song', 3)
const songs = factory<Song>('song', 3)
const postMock = this.mock(httpService, 'post').mockResolvedValue(playlist)
const removeMock = this.mock(cache, 'remove')
@ -124,7 +124,7 @@ new class extends UnitTestCase {
it('removes songs from a playlist', async () => {
const playlist = factory<Playlist>('playlist', { id: 12 })
const songs = factory<Song[]>('song', 3)
const songs = factory<Song>('song', 3)
const deleteMock = this.mock(httpService, 'delete').mockResolvedValue(playlist)
const removeMock = this.mock(cache, 'remove')
@ -138,10 +138,10 @@ new class extends UnitTestCase {
const playlist = factory.states('smart')<Playlist>('playlist')
const postMock = this.mock(httpService, 'post')
await playlistStore.addSongs(playlist, factory<Song[]>('song', 3))
await playlistStore.addSongs(playlist, factory<Song>('song', 3))
expect(postMock).not.toHaveBeenCalled()
await playlistStore.removeSongs(playlist, factory<Song[]>('song', 3))
await playlistStore.removeSongs(playlist, factory<Song>('song', 3))
expect(postMock).not.toHaveBeenCalled()
})
@ -157,7 +157,7 @@ new class extends UnitTestCase {
it('updates a smart playlist', async () => {
const playlist = factory.states('smart')<Playlist>('playlist', { id: 12 })
const rules = factory<SmartPlaylistRuleGroup[]>('smart-playlist-rule-group', 2)
const rules = factory<SmartPlaylistRuleGroup>('smart-playlist-rule-group', 2)
const serializeMock = this.mock(playlistStore, 'serializeSmartPlaylistRulesForStorage', ['Whatever'])
const putMock = this.mock(httpService, 'put').mockResolvedValue(playlist)
const removeMock = this.mock(cache, 'remove')

View file

@ -2,7 +2,8 @@ import { DeepReadonly, InjectionKey, Ref } from 'vue'
import DialogBox from '@/components/ui/DialogBox.vue'
import MessageToaster from '@/components/ui/MessageToaster.vue'
export type ReadonlyInjectionKey<T> = InjectionKey<[Readonly<T> | DeepReadonly<T>, Closure]>
export interface ReadonlyInjectionKey<T> extends InjectionKey<[Readonly<T> | DeepReadonly<T>, Closure]> {
}
export const DialogBoxKey: InjectionKey<Ref<InstanceType<typeof DialogBox>>> = Symbol('DialogBox')
export const MessageToasterKey: InjectionKey<Ref<InstanceType<typeof MessageToaster>>> = Symbol('MessageToaster')

View file

@ -194,6 +194,16 @@ type SmartPlaylistInputTypes = Record<SmartPlaylistModel['type'], SmartPlaylistO
type PlaylistType = 'playlist' | 'favorites' | 'recently-played'
type FavoriteList = {
name: 'Favorites'
songs: Song[]
}
type RecentlyPlayedList = {
name: 'Recently Played'
songs: Song[]
}
interface Playlist {
type: 'playlists'
readonly id: number
@ -203,6 +213,8 @@ interface Playlist {
rules: SmartPlaylistRuleGroup[]
}
type PlaylistLike = Playlist | FavoriteList | RecentlyPlayedList
interface PlaylistFolder {
type: 'playlist-folders'
readonly id: string

View file

@ -36,4 +36,9 @@ abstract class TestCase extends BaseTestCase
{
return $this->jsonAs($user, 'put', $url, $data);
}
protected function patchAs(string $url, array $data, ?User $user = null): TestResponse
{
return $this->jsonAs($user, 'patch', $url, $data);
}
}

View file

@ -26,6 +26,28 @@ class PlaylistFolderTest extends TestCase
$this->assertDatabaseHas(PlaylistFolder::class, ['name' => 'Classical', 'user_id' => $user->id]);
}
public function testUpdate(): void
{
/** @var PlaylistFolder $folder */
$folder = PlaylistFolder::factory()->create(['name' => 'Metal']);
$this->patchAs('api/playlist-folders/' . $folder->id, ['name' => 'Classical'], $folder->user)
->assertJsonStructure(self::JSON_STRUCTURE);
self::assertSame('Classical', $folder->fresh()->name);
}
public function testUnauthorizedUpdate(): void
{
/** @var PlaylistFolder $folder */
$folder = PlaylistFolder::factory()->create(['name' => 'Metal']);
$this->patchAs('api/playlist-folders/' . $folder->id, ['name' => 'Classical'])
->assertForbidden();
self::assertSame('Metal', $folder->fresh()->name);
}
public function testDelete(): void
{
/** @var PlaylistFolder $folder */

View file

@ -2,8 +2,11 @@
namespace Tests\Unit\Services;
use App\Models\Playlist;
use App\Models\PlaylistFolder;
use App\Models\User;
use App\Services\PlaylistFolderService;
use Illuminate\Support\Collection;
use Tests\TestCase;
class PlaylistFolderServiceTest extends TestCase
@ -29,4 +32,42 @@ class PlaylistFolderServiceTest extends TestCase
self::assertCount(1, $user->refresh()->playlist_folders);
self::assertSame('Classical', $user->playlist_folders[0]->name);
}
public function testUpdate(): void
{
/** @var PlaylistFolder $folder */
$folder = PlaylistFolder::factory()->create(['name' => 'Metal']);
$this->service->renameFolder($folder, 'Classical');
self::assertSame('Classical', $folder->fresh()->name);
}
public function testAddPlaylistsToFolder(): void
{
/** @var Collection|array<array-key, Playlist> $playlists */
$playlists = Playlist::factory()->count(3)->create();
/** @var PlaylistFolder $folder */
$folder = PlaylistFolder::factory()->create();
$this->service->addPlaylistsToFolder($folder, $playlists->pluck('id')->toArray());
self::assertCount(3, $folder->playlists);
}
public function testMovePlaylistsToRootLevel(): void
{
/** @var PlaylistFolder $folder */
$folder = PlaylistFolder::factory()->create();
/** @var Collection|array<array-key, Playlist> $playlists */
$playlists = Playlist::factory()->count(3)->create(['folder_id' => $folder->id]);
$this->service->movePlaylistsToRootLevel($playlists->pluck('id')->toArray());
self::assertCount(0, $folder->playlists);
$playlists->each(static fn (Playlist $playlist) => self::assertNull($playlist->refresh()->folder_id));
}
}