mirror of
https://github.com/koel/koel
synced 2024-11-28 06:50:27 +00:00
feat: support creating playlist directly from songs (#1617)
This commit is contained in:
parent
0b486e699b
commit
baa2e45a5d
22 changed files with 225 additions and 204 deletions
|
@ -40,8 +40,12 @@ const close = () => {
|
||||||
|
|
||||||
eventBus.on('MODAL_SHOW_ABOUT_KOEL', () => (activeModalName.value = 'about-koel'))
|
eventBus.on('MODAL_SHOW_ABOUT_KOEL', () => (activeModalName.value = 'about-koel'))
|
||||||
.on('MODAL_SHOW_ADD_USER_FORM', () => (activeModalName.value = 'add-user-form'))
|
.on('MODAL_SHOW_ADD_USER_FORM', () => (activeModalName.value = 'add-user-form'))
|
||||||
.on('MODAL_SHOW_CREATE_PLAYLIST_FORM', folder => {
|
.on('MODAL_SHOW_CREATE_PLAYLIST_FORM', (folder, songs) => {
|
||||||
context.value = { folder }
|
context.value = {
|
||||||
|
folder,
|
||||||
|
songs: songs ? arrayify(songs) : []
|
||||||
|
}
|
||||||
|
|
||||||
activeModalName.value = 'create-playlist-form'
|
activeModalName.value = 'create-playlist-form'
|
||||||
})
|
})
|
||||||
.on('MODAL_SHOW_CREATE_SMART_PLAYLIST_FORM', folder => {
|
.on('MODAL_SHOW_CREATE_SMART_PLAYLIST_FORM', folder => {
|
||||||
|
|
|
@ -1,10 +1,10 @@
|
||||||
<template>
|
<template>
|
||||||
<ContextMenuBase ref="base">
|
<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-simple" @click="onItemClicked('new-playlist')">New Playlist…</li>
|
||||||
<li data-testid="playlist-context-menu-create-smart" @click="onItemClicked('new-smart-playlist')">
|
<li data-testid="playlist-context-menu-create-smart" @click="onItemClicked('new-smart-playlist')">
|
||||||
New Smart Playlist
|
New Smart Playlist…
|
||||||
</li>
|
</li>
|
||||||
<li data-testid="playlist-context-menu-create-folder" @click="onItemClicked('new-folder')">New Folder</li>
|
<li data-testid="playlist-context-menu-create-folder" @click="onItemClicked('new-folder')">New Folder…</li>
|
||||||
</ContextMenuBase>
|
</ContextMenuBase>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|
|
@ -9,9 +9,10 @@ import CreatePlaylistForm from './CreatePlaylistForm.vue'
|
||||||
|
|
||||||
new class extends UnitTestCase {
|
new class extends UnitTestCase {
|
||||||
protected test () {
|
protected test () {
|
||||||
it('submits', async () => {
|
it('creates playlist with no songs', async () => {
|
||||||
const folder = factory<PlaylistFolder>('playlist-folder')
|
const folder = factory<PlaylistFolder>('playlist-folder')
|
||||||
const storeMock = this.mock(playlistStore, 'store').mockResolvedValue(factory<Playlist>('playlist'))
|
const storeMock = this.mock(playlistStore, 'store').mockResolvedValue(factory<Playlist>('playlist'))
|
||||||
|
|
||||||
this.render(CreatePlaylistForm, {
|
this.render(CreatePlaylistForm, {
|
||||||
global: {
|
global: {
|
||||||
provide: {
|
provide: {
|
||||||
|
@ -20,12 +21,37 @@ new class extends UnitTestCase {
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
expect(screen.queryByTestId('from-songs')).toBeNull()
|
||||||
|
|
||||||
await this.type(screen.getByPlaceholderText('Playlist name'), 'My playlist')
|
await this.type(screen.getByPlaceholderText('Playlist name'), 'My playlist')
|
||||||
await this.user.click(screen.getByRole('button', { name: 'Save' }))
|
await this.user.click(screen.getByRole('button', { name: 'Save' }))
|
||||||
|
|
||||||
expect(storeMock).toHaveBeenCalledWith('My playlist', {
|
expect(storeMock).toHaveBeenCalledWith('My playlist', {
|
||||||
folder_id: folder.id
|
folder_id: folder.id
|
||||||
|
}, [])
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('creates playlist with songs', async () => {
|
||||||
|
const songs = factory<Song>('song', 3)
|
||||||
|
const folder = factory<PlaylistFolder>('playlist-folder')
|
||||||
|
const storeMock = this.mock(playlistStore, 'store').mockResolvedValue(factory<Playlist>('playlist'))
|
||||||
|
|
||||||
|
this.render(CreatePlaylistForm, {
|
||||||
|
global: {
|
||||||
|
provide: {
|
||||||
|
[<symbol>ModalContextKey]: [ref({ folder, songs })]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
screen.getByText('from 3 songs')
|
||||||
|
|
||||||
|
await this.type(screen.getByPlaceholderText('Playlist name'), 'My playlist')
|
||||||
|
await this.user.click(screen.getByRole('button', { name: 'Save' }))
|
||||||
|
|
||||||
|
expect(storeMock).toHaveBeenCalledWith('My playlist', {
|
||||||
|
folder_id: folder.id
|
||||||
|
}, songs)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,7 +1,16 @@
|
||||||
<template>
|
<template>
|
||||||
<form @submit.prevent="submit" @keydown.esc="maybeClose">
|
<form @submit.prevent="submit" @keydown.esc="maybeClose">
|
||||||
<header>
|
<header>
|
||||||
<h1>New Playlist</h1>
|
<h1>
|
||||||
|
New Playlist
|
||||||
|
<span
|
||||||
|
v-if="songs.length"
|
||||||
|
data-testid="from-songs"
|
||||||
|
class="text-secondary"
|
||||||
|
>
|
||||||
|
from {{ pluralize(songs, 'song') }}
|
||||||
|
</span>
|
||||||
|
</h1>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<main>
|
<main>
|
||||||
|
@ -37,7 +46,7 @@
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { ref, toRef } from 'vue'
|
import { ref, toRef } from 'vue'
|
||||||
import { playlistFolderStore, playlistStore } from '@/stores'
|
import { playlistFolderStore, playlistStore } from '@/stores'
|
||||||
import { logger } from '@/utils'
|
import { logger, pluralize } from '@/utils'
|
||||||
import { useDialogBox, useMessageToaster, useModal, useOverlay, useRouter } from '@/composables'
|
import { useDialogBox, useMessageToaster, useModal, useOverlay, useRouter } from '@/composables'
|
||||||
|
|
||||||
import Btn from '@/components/ui/Btn.vue'
|
import Btn from '@/components/ui/Btn.vue'
|
||||||
|
@ -46,7 +55,10 @@ const { showOverlay, hideOverlay } = useOverlay()
|
||||||
const { toastSuccess } = useMessageToaster()
|
const { toastSuccess } = useMessageToaster()
|
||||||
const { showConfirmDialog, showErrorDialog } = useDialogBox()
|
const { showConfirmDialog, showErrorDialog } = useDialogBox()
|
||||||
const { go } = useRouter()
|
const { go } = useRouter()
|
||||||
const targetFolder = useModal().getFromContext<PlaylistFolder | null>('folder')
|
const { getFromContext } = useModal()
|
||||||
|
|
||||||
|
const targetFolder = getFromContext<PlaylistFolder | null>('folder') ?? null
|
||||||
|
const songs = getFromContext<Song[]>('songs') ?? []
|
||||||
|
|
||||||
const folderId = ref(targetFolder?.id)
|
const folderId = ref(targetFolder?.id)
|
||||||
const name = ref('')
|
const name = ref('')
|
||||||
|
@ -61,7 +73,7 @@ const submit = async () => {
|
||||||
try {
|
try {
|
||||||
const playlist = await playlistStore.store(name.value, {
|
const playlist = await playlistStore.store(name.value, {
|
||||||
folder_id: folderId.value
|
folder_id: folderId.value
|
||||||
})
|
}, songs)
|
||||||
|
|
||||||
close()
|
close()
|
||||||
toastSuccess(`Playlist "${playlist.name}" created.`)
|
toastSuccess(`Playlist "${playlist.name}" created.`)
|
||||||
|
|
|
@ -18,7 +18,7 @@ new class extends UnitTestCase {
|
||||||
await this.renderComponent(playlist)
|
await this.renderComponent(playlist)
|
||||||
const emitMock = this.mock(eventBus, 'emit')
|
const emitMock = this.mock(eventBus, 'emit')
|
||||||
|
|
||||||
await this.user.click(screen.getByText('Edit'))
|
await this.user.click(screen.getByText('Edit…'))
|
||||||
|
|
||||||
expect(emitMock).toHaveBeenCalledWith('MODAL_SHOW_EDIT_PLAYLIST_FORM', playlist)
|
expect(emitMock).toHaveBeenCalledWith('MODAL_SHOW_EDIT_PLAYLIST_FORM', playlist)
|
||||||
})
|
})
|
||||||
|
@ -28,7 +28,7 @@ new class extends UnitTestCase {
|
||||||
await this.renderComponent(playlist)
|
await this.renderComponent(playlist)
|
||||||
const emitMock = this.mock(eventBus, 'emit')
|
const emitMock = this.mock(eventBus, 'emit')
|
||||||
|
|
||||||
await this.user.click(screen.getByText('Edit'))
|
await this.user.click(screen.getByText('Edit…'))
|
||||||
|
|
||||||
expect(emitMock).toHaveBeenCalledWith('MODAL_SHOW_EDIT_PLAYLIST_FORM', playlist)
|
expect(emitMock).toHaveBeenCalledWith('MODAL_SHOW_EDIT_PLAYLIST_FORM', playlist)
|
||||||
})
|
})
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
<template>
|
<template>
|
||||||
<ContextMenuBase ref="base">
|
<ContextMenuBase ref="base">
|
||||||
<li @click="editPlaylist">Edit</li>
|
<li @click="editPlaylist">Edit…</li>
|
||||||
<li @click="deletePlaylist">Delete</li>
|
<li @click="deletePlaylist">Delete</li>
|
||||||
</ContextMenuBase>
|
</ContextMenuBase>
|
||||||
</template>
|
</template>
|
||||||
|
|
|
@ -6,8 +6,8 @@
|
||||||
<li @click="shuffle">Shuffle All</li>
|
<li @click="shuffle">Shuffle All</li>
|
||||||
<li class="separator" />
|
<li class="separator" />
|
||||||
</template>
|
</template>
|
||||||
<li @click="createPlaylist">Create Playlist</li>
|
<li @click="createPlaylist">New Playlist…</li>
|
||||||
<li @click="createSmartPlaylist">Create Smart Playlist</li>
|
<li @click="createSmartPlaylist">New Smart Playlist…</li>
|
||||||
<li class="separator" />
|
<li class="separator" />
|
||||||
<li @click="rename">Rename</li>
|
<li @click="rename">Rename</li>
|
||||||
<li @click="destroy">Delete</li>
|
<li @click="destroy">Delete</li>
|
||||||
|
|
|
@ -16,7 +16,6 @@
|
||||||
<template #controls>
|
<template #controls>
|
||||||
<SongListControls
|
<SongListControls
|
||||||
v-if="songs.length && (!isPhone || showingControls)"
|
v-if="songs.length && (!isPhone || showingControls)"
|
||||||
:config="controlConfig"
|
|
||||||
@clear-queue="clearQueue"
|
@clear-queue="clearQueue"
|
||||||
@play-all="playAll"
|
@play-all="playAll"
|
||||||
@play-selected="playSelected"
|
@play-selected="playSelected"
|
||||||
|
@ -63,8 +62,6 @@ import SongListSkeleton from '@/components/ui/skeletons/SongListSkeleton.vue'
|
||||||
const { go } = useRouter()
|
const { go } = useRouter()
|
||||||
const { showErrorDialog } = useDialogBox()
|
const { showErrorDialog } = useDialogBox()
|
||||||
|
|
||||||
const controlConfig: Partial<SongListControlsConfig> = { clearQueue: true }
|
|
||||||
|
|
||||||
const {
|
const {
|
||||||
SongList,
|
SongList,
|
||||||
SongListControls,
|
SongListControls,
|
||||||
|
|
|
@ -22,11 +22,7 @@ exports[`renders 1`] = `
|
||||||
<li data-testid="queue" tabindex="0" data-v-42061e3e="">Queue</li>
|
<li data-testid="queue" tabindex="0" data-v-42061e3e="">Queue</li>
|
||||||
<li class="favorites" data-testid="add-to-favorites" tabindex="0" data-v-42061e3e=""> Favorites </li>
|
<li class="favorites" data-testid="add-to-favorites" tabindex="0" data-v-42061e3e=""> Favorites </li>
|
||||||
</ul>
|
</ul>
|
||||||
</section>
|
</section><button type="button" transparent="" data-v-e368fe26="" data-v-42061e3e="">New Playlist…</button>
|
||||||
<section class="new-playlist" data-testid="new-playlist" data-v-42061e3e="">
|
|
||||||
<p data-v-42061e3e="">or create a new playlist</p>
|
|
||||||
<form class="form-save form-simple form-new-playlist" data-v-42061e3e=""><input data-testid="new-playlist-name" placeholder="Playlist name" required="" type="text" data-v-42061e3e=""><button type="submit" title="Save" data-v-e368fe26="" data-v-42061e3e="">⏎</button></form>
|
|
||||||
</section>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -4,7 +4,7 @@ import { expect, it } from 'vitest'
|
||||||
import factory from '@/__tests__/factory'
|
import factory from '@/__tests__/factory'
|
||||||
import { favoriteStore, playlistStore, queueStore } from '@/stores'
|
import { favoriteStore, playlistStore, queueStore } from '@/stores'
|
||||||
import UnitTestCase from '@/__tests__/UnitTestCase'
|
import UnitTestCase from '@/__tests__/UnitTestCase'
|
||||||
import { arrayify } from '@/utils'
|
import { arrayify, eventBus } from '@/utils'
|
||||||
import Btn from '@/components/ui/Btn.vue'
|
import Btn from '@/components/ui/Btn.vue'
|
||||||
import AddToMenu from './AddToMenu.vue'
|
import AddToMenu from './AddToMenu.vue'
|
||||||
|
|
||||||
|
@ -12,9 +12,7 @@ let songs: Song[]
|
||||||
|
|
||||||
const config: AddToMenuConfig = {
|
const config: AddToMenuConfig = {
|
||||||
queue: true,
|
queue: true,
|
||||||
favorites: true,
|
favorites: true
|
||||||
playlists: true,
|
|
||||||
newPlaylist: true
|
|
||||||
}
|
}
|
||||||
|
|
||||||
new class extends UnitTestCase {
|
new class extends UnitTestCase {
|
||||||
|
@ -49,8 +47,6 @@ new class extends UnitTestCase {
|
||||||
it.each<[keyof AddToMenuConfig, string | string[]]>([
|
it.each<[keyof AddToMenuConfig, string | string[]]>([
|
||||||
['queue', ['queue-after-current', 'queue-bottom', 'queue-top', 'queue']],
|
['queue', ['queue-after-current', 'queue-bottom', 'queue-top', 'queue']],
|
||||||
['favorites', 'add-to-favorites'],
|
['favorites', 'add-to-favorites'],
|
||||||
['playlists', 'add-to-playlist'],
|
|
||||||
['newPlaylist', 'new-playlist']
|
|
||||||
])('renders disabling %s config', (configKey: keyof AddToMenuConfig, testIds: string | string[]) => {
|
])('renders disabling %s config', (configKey: keyof AddToMenuConfig, testIds: string | string[]) => {
|
||||||
this.renderComponent({ [configKey]: false })
|
this.renderComponent({ [configKey]: false })
|
||||||
arrayify(testIds).forEach(id => expect(screen.queryByTestId(id)).toBeNull())
|
arrayify(testIds).forEach(id => expect(screen.queryByTestId(id)).toBeNull())
|
||||||
|
@ -90,5 +86,14 @@ new class extends UnitTestCase {
|
||||||
|
|
||||||
expect(mock).toHaveBeenCalledWith(playlistStore.state.playlists[1], songs)
|
expect(mock).toHaveBeenCalledWith(playlistStore.state.playlists[1], songs)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('creates playlist from selected songs', async () => {
|
||||||
|
const emitMock = this.mock(eventBus, 'emit')
|
||||||
|
this.renderComponent()
|
||||||
|
|
||||||
|
await this.user.click(screen.getByText('New Playlist…'))
|
||||||
|
|
||||||
|
expect(emitMock).toHaveBeenCalledWith('MODAL_SHOW_CREATE_PLAYLIST_FORM', null, songs)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -33,7 +33,6 @@
|
||||||
Favorites
|
Favorites
|
||||||
</li>
|
</li>
|
||||||
|
|
||||||
<template v-if="config.playlists">
|
|
||||||
<li
|
<li
|
||||||
v-for="playlist in playlists"
|
v-for="playlist in playlists"
|
||||||
:key="playlist.id"
|
:key="playlist.id"
|
||||||
|
@ -44,43 +43,24 @@
|
||||||
>
|
>
|
||||||
{{ playlist.name }}
|
{{ playlist.name }}
|
||||||
</li>
|
</li>
|
||||||
</template>
|
|
||||||
</ul>
|
</ul>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section v-if="config.newPlaylist" class="new-playlist" data-testid="new-playlist">
|
<Btn transparent @click.prevent="addSongsToNewPlaylist">New Playlist…</Btn>
|
||||||
<p>or create a new playlist</p>
|
|
||||||
|
|
||||||
<form class="form-save form-simple form-new-playlist" @submit.prevent="createNewPlaylistFromSongs">
|
|
||||||
<input
|
|
||||||
v-model="newPlaylistName"
|
|
||||||
data-testid="new-playlist-name"
|
|
||||||
placeholder="Playlist name"
|
|
||||||
required
|
|
||||||
type="text"
|
|
||||||
@keyup.esc.prevent="close"
|
|
||||||
>
|
|
||||||
<Btn title="Save" type="submit">⏎</Btn>
|
|
||||||
</form>
|
|
||||||
</section>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { computed, nextTick, ref, toRef, toRefs, watch } from 'vue'
|
import { computed, ref, toRef, toRefs, watch } from 'vue'
|
||||||
import { pluralize } from '@/utils'
|
import { pluralize } from '@/utils'
|
||||||
import { playlistStore, queueStore } from '@/stores'
|
import { playlistStore, queueStore } from '@/stores'
|
||||||
import { useMessageToaster, useRouter, useSongMenuMethods } from '@/composables'
|
import { useSongMenuMethods } from '@/composables'
|
||||||
|
|
||||||
import Btn from '@/components/ui/Btn.vue'
|
import Btn from '@/components/ui/Btn.vue'
|
||||||
|
|
||||||
const { toastSuccess } = useMessageToaster()
|
|
||||||
const { go } = useRouter()
|
|
||||||
|
|
||||||
const props = defineProps<{ songs: Song[], config: AddToMenuConfig }>()
|
const props = defineProps<{ songs: Song[], config: AddToMenuConfig }>()
|
||||||
const { songs, config } = toRefs(props)
|
const { songs, config } = toRefs(props)
|
||||||
|
|
||||||
const newPlaylistName = ref('')
|
|
||||||
const queue = toRef(queueStore.state, 'songs')
|
const queue = toRef(queueStore.state, 'songs')
|
||||||
const currentSong = queueStore.current
|
const currentSong = queueStore.current
|
||||||
|
|
||||||
|
@ -95,39 +75,18 @@ const {
|
||||||
queueSongsToBottom,
|
queueSongsToBottom,
|
||||||
queueSongsToTop,
|
queueSongsToTop,
|
||||||
addSongsToFavorite,
|
addSongsToFavorite,
|
||||||
addSongsToExistingPlaylist
|
addSongsToExistingPlaylist,
|
||||||
|
addSongsToNewPlaylist
|
||||||
} = useSongMenuMethods(songs, close)
|
} = useSongMenuMethods(songs, close)
|
||||||
|
|
||||||
watch(songs, () => songs.value.length || close())
|
watch(songs, () => songs.value.length || close())
|
||||||
|
|
||||||
/**
|
|
||||||
* Save the selected songs as a playlist.
|
|
||||||
* As of current we don't have selective save.
|
|
||||||
*/
|
|
||||||
const createNewPlaylistFromSongs = async () => {
|
|
||||||
newPlaylistName.value = newPlaylistName.value.trim()
|
|
||||||
|
|
||||||
if (!newPlaylistName.value) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const playlist = await playlistStore.store(newPlaylistName.value, {}, songs.value)
|
|
||||||
newPlaylistName.value = ''
|
|
||||||
|
|
||||||
toastSuccess(`Playlist "${playlist.name}" created.`)
|
|
||||||
|
|
||||||
// Activate the new playlist right away
|
|
||||||
await nextTick()
|
|
||||||
go(`playlist/${playlist.id}`)
|
|
||||||
|
|
||||||
close()
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
.add-to {
|
.add-to {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
max-width: 225px;
|
max-width: 256px;
|
||||||
|
min-width: 196px;
|
||||||
padding: .75rem;
|
padding: .75rem;
|
||||||
|
|
||||||
> * + * {
|
> * + * {
|
||||||
|
@ -170,29 +129,9 @@ const createNewPlaylistFromSongs = async () => {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
form {
|
button {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
display: flex;
|
border: 1px solid rgba(255, 255, 255, .2);
|
||||||
justify-content: center;
|
|
||||||
align-items: center;
|
|
||||||
border-radius: 3px;
|
|
||||||
overflow: hidden;
|
|
||||||
|
|
||||||
input[type="text"] {
|
|
||||||
width: 100%;
|
|
||||||
height: 28px;
|
|
||||||
border-radius: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
button[type="submit"] {
|
|
||||||
margin-top: 0;
|
|
||||||
border-radius: 0;
|
|
||||||
height: 28px;
|
|
||||||
line-height: 28px;
|
|
||||||
padding-top: 0;
|
|
||||||
padding-bottom: 0;
|
|
||||||
margin-left: -2px !important;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
|
@ -251,14 +251,14 @@ new class extends UnitTestCase {
|
||||||
|
|
||||||
// mock after render to ensure that the component is mounted properly
|
// mock after render to ensure that the component is mounted properly
|
||||||
const emitMock = this.mock(eventBus, 'emit')
|
const emitMock = this.mock(eventBus, 'emit')
|
||||||
await this.user.click(screen.getByText('Edit'))
|
await this.user.click(screen.getByText('Edit…'))
|
||||||
|
|
||||||
expect(emitMock).toHaveBeenCalledWith('MODAL_SHOW_EDIT_SONG_FORM', songs)
|
expect(emitMock).toHaveBeenCalledWith('MODAL_SHOW_EDIT_SONG_FORM', songs)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('does not allow edit songs if current user is not admin', async () => {
|
it('does not allow edit songs if current user is not admin', async () => {
|
||||||
await this.actingAs().renderComponent()
|
await this.actingAs().renderComponent()
|
||||||
expect(screen.queryByText('Edit')).toBeNull()
|
expect(screen.queryByText('Edit…')).toBeNull()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('has an option to copy shareable URL', async () => {
|
it('has an option to copy shareable URL', async () => {
|
||||||
|
@ -288,5 +288,16 @@ new class extends UnitTestCase {
|
||||||
await this.actingAs().renderComponent()
|
await this.actingAs().renderComponent()
|
||||||
expect(screen.queryByText('Delete from Filesystem')).toBeNull()
|
expect(screen.queryByText('Delete from Filesystem')).toBeNull()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('creates playlist from selected songs', async () => {
|
||||||
|
await this.actingAs().renderComponent()
|
||||||
|
|
||||||
|
// mock after render to ensure that the component is mounted properly
|
||||||
|
const emitMock = this.mock(eventBus, 'emit')
|
||||||
|
|
||||||
|
await this.user.click(screen.getByText('New Playlist…'))
|
||||||
|
|
||||||
|
expect(emitMock).toHaveBeenCalledWith('MODAL_SHOW_CREATE_PLAYLIST_FORM', null, songs)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -22,8 +22,12 @@
|
||||||
<li @click="addSongsToFavorite">Favorites</li>
|
<li @click="addSongsToFavorite">Favorites</li>
|
||||||
</template>
|
</template>
|
||||||
<li v-if="normalPlaylists.length" class="separator" />
|
<li v-if="normalPlaylists.length" class="separator" />
|
||||||
|
<ul v-if="normalPlaylists.length" class="normal-playlists">
|
||||||
<li v-for="p in normalPlaylists" :key="p.id" @click="addSongsToExistingPlaylist(p)">{{ p.name }}</li>
|
<li v-for="p in normalPlaylists" :key="p.id" @click="addSongsToExistingPlaylist(p)">{{ p.name }}</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
<li class="separator" />
|
||||||
|
<li @click="addSongsToNewPlaylist">New Playlist…</li>
|
||||||
|
</ul>
|
||||||
</li>
|
</li>
|
||||||
|
|
||||||
<template v-if="isQueueScreen">
|
<template v-if="isQueueScreen">
|
||||||
|
@ -38,7 +42,7 @@
|
||||||
<li class="separator" />
|
<li class="separator" />
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<li v-if="isAdmin" @click="openEditForm">Edit</li>
|
<li v-if="isAdmin" @click="openEditForm">Edit…</li>
|
||||||
<li v-if="allowDownload" @click="download">Download</li>
|
<li v-if="allowDownload" @click="download">Download</li>
|
||||||
<li v-if="onlyOneSongSelected" @click="copyUrl">Copy Shareable URL</li>
|
<li v-if="onlyOneSongSelected" @click="copyUrl">Copy Shareable URL</li>
|
||||||
|
|
||||||
|
@ -83,12 +87,12 @@ const {
|
||||||
queueSongsToBottom,
|
queueSongsToBottom,
|
||||||
queueSongsToTop,
|
queueSongsToTop,
|
||||||
addSongsToFavorite,
|
addSongsToFavorite,
|
||||||
addSongsToExistingPlaylist
|
addSongsToExistingPlaylist,
|
||||||
|
addSongsToNewPlaylist
|
||||||
} = useSongMenuMethods(songs, close)
|
} = useSongMenuMethods(songs, close)
|
||||||
|
|
||||||
const playlists = toRef(playlistStore.state, 'playlists')
|
const playlists = toRef(playlistStore.state, 'playlists')
|
||||||
const allowDownload = toRef(commonStore.state, 'allow_download')
|
const allowDownload = toRef(commonStore.state, 'allow_download')
|
||||||
const user = toRef(userStore.state, 'current')
|
|
||||||
const queue = toRef(queueStore.state, 'songs')
|
const queue = toRef(queueStore.state, 'songs')
|
||||||
const currentSong = toRef(queueStore, 'current')
|
const currentSong = toRef(queueStore, 'current')
|
||||||
|
|
||||||
|
@ -157,3 +161,10 @@ eventBus.on('SONG_CONTEXT_MENU_REQUESTED', async (e, _songs) => {
|
||||||
await open(e.pageY, e.pageX)
|
await open(e.pageY, e.pageX)
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
ul.normal-playlists {
|
||||||
|
max-height: 256px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
|
@ -8,13 +8,15 @@ import { screen } from '@testing-library/vue'
|
||||||
import SongListControls from './SongListControls.vue'
|
import SongListControls from './SongListControls.vue'
|
||||||
|
|
||||||
new class extends UnitTestCase {
|
new class extends UnitTestCase {
|
||||||
private renderComponent (selectedSongCount = 1, config: Partial<SongListControlsConfig> = {}) {
|
private renderComponent (selectedSongCount = 1, screen: ScreenName = 'Songs') {
|
||||||
const songs = factory<Song>('song', 5)
|
const songs = factory<Song>('song', 5)
|
||||||
|
|
||||||
|
this.router.activateRoute({
|
||||||
|
screen,
|
||||||
|
path: '_',
|
||||||
|
})
|
||||||
|
|
||||||
return this.render(SongListControls, {
|
return this.render(SongListControls, {
|
||||||
props: {
|
|
||||||
config
|
|
||||||
},
|
|
||||||
global: {
|
global: {
|
||||||
provide: {
|
provide: {
|
||||||
[<symbol>SongsKey]: [ref(songs)],
|
[<symbol>SongsKey]: [ref(songs)],
|
||||||
|
@ -62,7 +64,7 @@ new class extends UnitTestCase {
|
||||||
})
|
})
|
||||||
|
|
||||||
it('clears queue', async () => {
|
it('clears queue', async () => {
|
||||||
const { emitted } = this.renderComponent(0, { clearQueue: true })
|
const { emitted } = this.renderComponent(0, 'Queue')
|
||||||
|
|
||||||
await this.user.click(screen.getByTitle('Clear current queue'))
|
await this.user.click(screen.getByTitle('Clear current queue'))
|
||||||
|
|
||||||
|
@ -70,7 +72,7 @@ new class extends UnitTestCase {
|
||||||
})
|
})
|
||||||
|
|
||||||
it('deletes current playlist', async () => {
|
it('deletes current playlist', async () => {
|
||||||
const { emitted } = this.renderComponent(0, { deletePlaylist: true })
|
const { emitted } = this.renderComponent(0, 'Playlist')
|
||||||
|
|
||||||
await this.user.click(screen.getByTitle('Delete this playlist'))
|
await this.user.click(screen.getByTitle('Delete this playlist'))
|
||||||
|
|
||||||
|
|
|
@ -2,7 +2,6 @@
|
||||||
<div ref="el" class="song-list-controls" data-testid="song-list-controls">
|
<div ref="el" class="song-list-controls" data-testid="song-list-controls">
|
||||||
<div class="wrapper">
|
<div class="wrapper">
|
||||||
<BtnGroup uppercased>
|
<BtnGroup uppercased>
|
||||||
<template v-if="mergedConfig.play">
|
|
||||||
<template v-if="altPressed">
|
<template v-if="altPressed">
|
||||||
<Btn
|
<Btn
|
||||||
v-if="selectedSongs.length < 2 && songs.length"
|
v-if="selectedSongs.length < 2 && songs.length"
|
||||||
|
@ -52,13 +51,16 @@
|
||||||
Selected
|
Selected
|
||||||
</Btn>
|
</Btn>
|
||||||
</template>
|
</template>
|
||||||
</template>
|
|
||||||
|
|
||||||
<Btn v-if="showAddToButton" ref="addToButton" green @click.prevent.stop="toggleAddToMenu">
|
<Btn
|
||||||
|
v-if="showAddToButton"
|
||||||
|
ref="addToButton"
|
||||||
|
green @click.prevent.stop="toggleAddToMenu"
|
||||||
|
>
|
||||||
{{ showingAddToMenu ? 'Cancel' : 'Add To…' }}
|
{{ showingAddToMenu ? 'Cancel' : 'Add To…' }}
|
||||||
</Btn>
|
</Btn>
|
||||||
|
|
||||||
<Btn v-if="showClearQueueButton" red title="Clear current queue" @click.prevent="clearQueue">Clear</Btn>
|
<Btn v-if="config.clearQueue" red title="Clear current queue" @click.prevent="clearQueue">Clear</Btn>
|
||||||
</BtnGroup>
|
</BtnGroup>
|
||||||
|
|
||||||
<BtnGroup>
|
<BtnGroup>
|
||||||
|
@ -67,7 +69,7 @@
|
||||||
</Btn>
|
</Btn>
|
||||||
|
|
||||||
<Btn
|
<Btn
|
||||||
v-if="showDeletePlaylistButton"
|
v-if="config.deletePlaylist"
|
||||||
v-koel-tooltip
|
v-koel-tooltip
|
||||||
class="del btn-delete-playlist"
|
class="del btn-delete-playlist"
|
||||||
red
|
red
|
||||||
|
@ -80,7 +82,7 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div ref="addToMenu" v-koel-clickaway="closeAddToMenu" class="menu-wrapper">
|
<div ref="addToMenu" v-koel-clickaway="closeAddToMenu" class="menu-wrapper">
|
||||||
<AddToMenu :config="mergedConfig.addTo" :songs="selectedSongs" @closing="closeAddToMenu" />
|
<AddToMenu :config="config.addTo" :songs="selectedSongs" @closing="closeAddToMenu" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
@ -90,14 +92,13 @@ import { faPlay, faRandom, faRotateRight, faTrashCan } from '@fortawesome/free-s
|
||||||
import { computed, nextTick, onBeforeUnmount, onMounted, Ref, ref, toRefs, watch } from 'vue'
|
import { computed, nextTick, onBeforeUnmount, onMounted, Ref, ref, toRefs, watch } from 'vue'
|
||||||
import { SelectedSongsKey, SongsKey } from '@/symbols'
|
import { SelectedSongsKey, SongsKey } from '@/symbols'
|
||||||
import { requireInjection } from '@/utils'
|
import { requireInjection } from '@/utils'
|
||||||
import { useFloatingUi } from '@/composables'
|
import { useFloatingUi, useSongListControls } from '@/composables'
|
||||||
|
|
||||||
import AddToMenu from '@/components/song/AddToMenu.vue'
|
import AddToMenu from '@/components/song/AddToMenu.vue'
|
||||||
import Btn from '@/components/ui/Btn.vue'
|
import Btn from '@/components/ui/Btn.vue'
|
||||||
import BtnGroup from '@/components/ui/BtnGroup.vue'
|
import BtnGroup from '@/components/ui/BtnGroup.vue'
|
||||||
|
|
||||||
const props = withDefaults(defineProps<{ config?: Partial<SongListControlsConfig> }>(), { config: () => ({}) })
|
const config = useSongListControls().getSongListControlsConfig()
|
||||||
const { config } = toRefs(props)
|
|
||||||
|
|
||||||
const [songs] = requireInjection<[Ref<Song[]>]>(SongsKey)
|
const [songs] = requireInjection<[Ref<Song[]>]>(SongsKey)
|
||||||
const [selectedSongs] = requireInjection(SelectedSongsKey)
|
const [selectedSongs] = requireInjection(SelectedSongsKey)
|
||||||
|
@ -108,23 +109,7 @@ const addToMenu = ref<HTMLDivElement>()
|
||||||
const showingAddToMenu = ref(false)
|
const showingAddToMenu = ref(false)
|
||||||
const altPressed = ref(false)
|
const altPressed = ref(false)
|
||||||
|
|
||||||
const mergedConfig = computed((): SongListControlsConfig => Object.assign({
|
|
||||||
play: true,
|
|
||||||
addTo: {
|
|
||||||
queue: true,
|
|
||||||
favorites: true,
|
|
||||||
playlists: true,
|
|
||||||
newPlaylist: true
|
|
||||||
},
|
|
||||||
clearQueue: false,
|
|
||||||
deletePlaylist: false,
|
|
||||||
refresh: false
|
|
||||||
}, config.value)
|
|
||||||
)
|
|
||||||
|
|
||||||
const showAddToButton = computed(() => Boolean(selectedSongs.value.length))
|
const showAddToButton = computed(() => Boolean(selectedSongs.value.length))
|
||||||
const showClearQueueButton = computed(() => mergedConfig.value.clearQueue)
|
|
||||||
const showDeletePlaylistButton = computed(() => mergedConfig.value.deletePlaylist)
|
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
(e: 'playAll' | 'playSelected', shuffle: boolean): void,
|
(e: 'playAll' | 'playSelected', shuffle: boolean): void,
|
||||||
|
|
|
@ -11,10 +11,6 @@ exports[`renders 1`] = `
|
||||||
<li class="playlist" data-testid="add-to-playlist" tabindex="0" data-v-42061e3e="">Bar</li>
|
<li class="playlist" data-testid="add-to-playlist" tabindex="0" data-v-42061e3e="">Bar</li>
|
||||||
<li class="playlist" data-testid="add-to-playlist" tabindex="0" data-v-42061e3e="">Baz</li>
|
<li class="playlist" data-testid="add-to-playlist" tabindex="0" data-v-42061e3e="">Baz</li>
|
||||||
</ul>
|
</ul>
|
||||||
</section>
|
</section><button type="button" transparent="" data-v-e368fe26="" data-v-42061e3e="">New Playlist…</button>
|
||||||
<section class="new-playlist" data-testid="new-playlist" data-v-42061e3e="">
|
|
||||||
<p data-v-42061e3e="">or create a new playlist</p>
|
|
||||||
<form class="form-save form-simple form-new-playlist" data-v-42061e3e=""><input data-testid="new-playlist-name" placeholder="Playlist name" required="" type="text" data-v-42061e3e=""><button type="submit" title="Save" data-v-e368fe26="" data-v-42061e3e="">⏎</button></form>
|
|
||||||
</section>
|
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
|
|
@ -13,6 +13,7 @@ export * from './usePlaylistManagement'
|
||||||
export * from './useRouter'
|
export * from './useRouter'
|
||||||
export * from './useSmartPlaylistForm'
|
export * from './useSmartPlaylistForm'
|
||||||
export * from './useSongList'
|
export * from './useSongList'
|
||||||
|
export * from './useSongListControls'
|
||||||
export * from './useSongMenuMethods'
|
export * from './useSongMenuMethods'
|
||||||
export * from './useThirdPartyServices'
|
export * from './useThirdPartyServices'
|
||||||
export * from './useUpload'
|
export * from './useUpload'
|
||||||
|
|
31
resources/assets/js/composables/useSongListControls.ts
Normal file
31
resources/assets/js/composables/useSongListControls.ts
Normal file
|
@ -0,0 +1,31 @@
|
||||||
|
import { useRouter } from '@/composables'
|
||||||
|
|
||||||
|
export const useSongListControls = () => {
|
||||||
|
const { isCurrentScreen } = useRouter()
|
||||||
|
|
||||||
|
const getSongListControlsConfig = () => {
|
||||||
|
|
||||||
|
const config: SongListControlsConfig = {
|
||||||
|
play: true,
|
||||||
|
addTo: {
|
||||||
|
queue: true,
|
||||||
|
favorites: true,
|
||||||
|
},
|
||||||
|
clearQueue: true,
|
||||||
|
deletePlaylist: true,
|
||||||
|
refresh: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
config.clearQueue = isCurrentScreen('Queue')
|
||||||
|
config.addTo.queue = !isCurrentScreen('Queue')
|
||||||
|
config.addTo.favorites = !isCurrentScreen('Favorites')
|
||||||
|
config.deletePlaylist = isCurrentScreen('Playlist')
|
||||||
|
config.refresh = isCurrentScreen('Playlist')
|
||||||
|
|
||||||
|
return config
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
getSongListControlsConfig
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,6 +1,7 @@
|
||||||
import { Ref } from 'vue'
|
import { Ref } from 'vue'
|
||||||
import { favoriteStore, queueStore } from '@/stores'
|
import { favoriteStore, queueStore } from '@/stores'
|
||||||
import { usePlaylistManagement } from '@/composables'
|
import { usePlaylistManagement } from '@/composables'
|
||||||
|
import { eventBus } from '@/utils'
|
||||||
|
|
||||||
export const useSongMenuMethods = (songs: Ref<Song[]>, close: Closure) => {
|
export const useSongMenuMethods = (songs: Ref<Song[]>, close: Closure) => {
|
||||||
const { addSongsToPlaylist } = usePlaylistManagement()
|
const { addSongsToPlaylist } = usePlaylistManagement()
|
||||||
|
@ -30,11 +31,17 @@ export const useSongMenuMethods = (songs: Ref<Song[]>, close: Closure) => {
|
||||||
await addSongsToPlaylist(playlist, songs.value)
|
await addSongsToPlaylist(playlist, songs.value)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const addSongsToNewPlaylist = () => {
|
||||||
|
close()
|
||||||
|
eventBus.emit('MODAL_SHOW_CREATE_PLAYLIST_FORM', null, songs.value)
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
queueSongsAfterCurrent,
|
queueSongsAfterCurrent,
|
||||||
queueSongsToBottom,
|
queueSongsToBottom,
|
||||||
queueSongsToTop,
|
queueSongsToTop,
|
||||||
addSongsToFavorite,
|
addSongsToFavorite,
|
||||||
addSongsToExistingPlaylist
|
addSongsToExistingPlaylist,
|
||||||
|
addSongsToNewPlaylist
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -18,9 +18,9 @@ export interface Events {
|
||||||
MODAL_SHOW_ADD_USER_FORM: () => void
|
MODAL_SHOW_ADD_USER_FORM: () => void
|
||||||
MODAL_SHOW_EDIT_USER_FORM: (user: User) => void
|
MODAL_SHOW_EDIT_USER_FORM: (user: User) => void
|
||||||
MODAL_SHOW_EDIT_SONG_FORM: (songs: Song | Song[], initialTab?: EditSongFormTabName) => void
|
MODAL_SHOW_EDIT_SONG_FORM: (songs: Song | Song[], initialTab?: EditSongFormTabName) => void
|
||||||
MODAL_SHOW_CREATE_PLAYLIST_FORM: (folder: PlaylistFolder | null) => void
|
MODAL_SHOW_CREATE_PLAYLIST_FORM: (folder?: PlaylistFolder | null, songs?: Song | Song[]) => void
|
||||||
MODAL_SHOW_EDIT_PLAYLIST_FORM: (playlist: Playlist) => void
|
MODAL_SHOW_EDIT_PLAYLIST_FORM: (playlist: Playlist) => void
|
||||||
MODAL_SHOW_CREATE_SMART_PLAYLIST_FORM: (folder: PlaylistFolder | null) => void
|
MODAL_SHOW_CREATE_SMART_PLAYLIST_FORM: (folder?: PlaylistFolder | null) => void
|
||||||
MODAL_SHOW_CREATE_PLAYLIST_FOLDER_FORM: () => void
|
MODAL_SHOW_CREATE_PLAYLIST_FOLDER_FORM: () => void
|
||||||
MODAL_SHOW_EDIT_PLAYLIST_FOLDER_FORM: (playlistFolder: PlaylistFolder) => void
|
MODAL_SHOW_EDIT_PLAYLIST_FOLDER_FORM: (playlistFolder: PlaylistFolder) => void
|
||||||
MODAL_SHOW_ABOUT_KOEL: () => void
|
MODAL_SHOW_ABOUT_KOEL: () => void
|
||||||
|
|
|
@ -117,7 +117,7 @@ export const playlistStore = {
|
||||||
})
|
})
|
||||||
|
|
||||||
playlist.is_smart && cache.remove(['playlist.songs', playlist.id])
|
playlist.is_smart && cache.remove(['playlist.songs', playlist.id])
|
||||||
Object.assign(this.byId(playlist.id), data)
|
Object.assign(this.byId(playlist.id)!, data)
|
||||||
},
|
},
|
||||||
|
|
||||||
createEmptySmartPlaylistRule: (): SmartPlaylistRule => ({
|
createEmptySmartPlaylistRule: (): SmartPlaylistRule => ({
|
||||||
|
|
2
resources/assets/js/types.d.ts
vendored
2
resources/assets/js/types.d.ts
vendored
|
@ -313,8 +313,6 @@ declare type ArtistAlbumCardLayout = 'full' | 'compact'
|
||||||
interface AddToMenuConfig {
|
interface AddToMenuConfig {
|
||||||
queue: boolean
|
queue: boolean
|
||||||
favorites: boolean
|
favorites: boolean
|
||||||
playlists: boolean
|
|
||||||
newPlaylist: boolean
|
|
||||||
}
|
}
|
||||||
|
|
||||||
interface SongListControlsConfig {
|
interface SongListControlsConfig {
|
||||||
|
|
Loading…
Reference in a new issue