mirror of
https://github.com/koel/koel
synced 2024-11-27 22:40:26 +00:00
test: add tests for playlist folder functionalities
This commit is contained in:
parent
e8a1cdece7
commit
1730e19d21
58 changed files with 779 additions and 296 deletions
|
@ -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)
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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]);
|
||||
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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
|
||||
})
|
||||
}
|
||||
|
|
|
@ -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()
|
||||
})
|
|
@ -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()]
|
||||
})
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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) => {
|
||||
|
|
|
@ -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>
|
||||
`;
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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))
|
||||
})
|
||||
}
|
||||
}
|
|
@ -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>
|
||||
|
|
|
@ -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')
|
||||
})
|
||||
}
|
||||
}
|
|
@ -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>
|
||||
|
|
|
@ -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')
|
||||
})
|
||||
}
|
||||
}
|
|
@ -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>
|
||||
|
|
|
@ -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')
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
|
@ -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')
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
})
|
||||
}
|
||||
}
|
|
@ -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>
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
|
@ -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
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -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))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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">
|
||||
|
|
|
@ -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 () => {
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
`;
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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')
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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'])
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
`;
|
|
@ -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>`;
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -19,7 +19,7 @@ new class extends UnitTestCase {
|
|||
const { getByLabelText, getByRole } = this.render(EditUserForm, {
|
||||
global: {
|
||||
provide: {
|
||||
[UserKey]: [user]
|
||||
[<symbol>UserKey]: [user]
|
||||
}
|
||||
}
|
||||
})
|
||||
|
|
|
@ -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.
|
||||
*/
|
|
@ -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'
|
||||
|
|
|
@ -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] })
|
||||
},
|
||||
|
|
|
@ -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')
|
||||
|
|
|
@ -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')
|
||||
|
|
12
resources/assets/js/types.d.ts
vendored
12
resources/assets/js/types.d.ts
vendored
|
@ -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
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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 */
|
||||
|
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
|
|
Loading…
Reference in a new issue