mirror of
https://github.com/koel/koel
synced 2024-11-10 06:34:14 +00:00
feat(test): add client test for collaborative playlists and more
This commit is contained in:
parent
259e96bdd3
commit
00eebaf225
24 changed files with 623 additions and 226 deletions
|
@ -23,7 +23,7 @@ class PlaylistCollaboratorResource extends JsonResource
|
|||
public function toArray($request): array
|
||||
{
|
||||
return [
|
||||
'type' => 'playlist_collaborators',
|
||||
'type' => 'playlist-collaborators',
|
||||
'id' => $this->collaborator->id,
|
||||
'name' => $this->collaborator->name,
|
||||
'avatar' => $this->collaborator->avatar,
|
||||
|
|
|
@ -24,7 +24,7 @@ class PlaylistFolderResource extends JsonResource
|
|||
public function toArray($request): array
|
||||
{
|
||||
return [
|
||||
'type' => 'playlist_folders',
|
||||
'type' => 'playlist-folders',
|
||||
'id' => $this->folder->id,
|
||||
'name' => $this->folder->name,
|
||||
'user_id' => $this->folder->user_id,
|
||||
|
|
|
@ -13,6 +13,7 @@ import albumInfoFactory from '@/__tests__/factory/albumInfoFactory'
|
|||
import artistInfoFactory from '@/__tests__/factory/artistInfoFactory'
|
||||
import youTubeVideoFactory from '@/__tests__/factory/youTubeVideoFactory'
|
||||
import genreFactory from '@/__tests__/factory/genreFactory'
|
||||
import playlistCollaboratorFactory from '@/__tests__/factory/playlistCollaboratorFactory'
|
||||
|
||||
export default factory
|
||||
.define('artist', faker => artistFactory(faker), artistStates)
|
||||
|
@ -29,3 +30,4 @@ export default factory
|
|||
.define('playlist', faker => playlistFactory(faker), playlistStates)
|
||||
.define('playlist-folder', faker => playlistFolderFactory(faker))
|
||||
.define('user', faker => userFactory(faker), userStates)
|
||||
.define('playlist-collaborator', faker => playlistCollaboratorFactory(faker))
|
||||
|
|
|
@ -0,0 +1,8 @@
|
|||
import { Faker } from '@faker-js/faker'
|
||||
|
||||
export default (faker: Faker): PlaylistCollaborator => ({
|
||||
type: 'playlist-collaborators',
|
||||
id: faker.datatype.number(),
|
||||
name: faker.name.findName(),
|
||||
avatar: 'https://gravatar.com/foo'
|
||||
})
|
|
@ -20,7 +20,7 @@ const modalNameToComponentMap = {
|
|||
'edit-song-form': defineAsyncComponent(() => import('@/components/song/EditSongForm.vue')),
|
||||
'create-playlist-folder-form': defineAsyncComponent(() => import('@/components/playlist/CreatePlaylistFolderForm.vue')),
|
||||
'edit-playlist-folder-form': defineAsyncComponent(() => import('@/components/playlist/EditPlaylistFolderForm.vue')),
|
||||
'playlist-collaboration': defineAsyncComponent(() => import('@/components/playlist/CollaborationModal.vue')),
|
||||
'playlist-collaboration': defineAsyncComponent(() => import('@/components/playlist/PlaylistCollaborationModal.vue')),
|
||||
'about-koel': defineAsyncComponent(() => import('@/components/meta/AboutKoelModal.vue')),
|
||||
'koel-plus': defineAsyncComponent(() => import('@/components/koel-plus/KoelPlusModal.vue')),
|
||||
'equalizer': defineAsyncComponent(() => import('@/components/ui/Equalizer.vue'))
|
||||
|
|
|
@ -1,209 +0,0 @@
|
|||
<template>
|
||||
<div class="collaboration-modal" tabindex="0" @keydown.esc="close">
|
||||
<header>
|
||||
<h1>Playlist Collaboration</h1>
|
||||
</header>
|
||||
|
||||
<main>
|
||||
<p class="intro text-secondary">
|
||||
Collaborative playlists allow multiple users to contribute. <br>
|
||||
Please note that songs added to a collaborative playlist are made accessible to all users,
|
||||
and you cannot mark a song as private if it’s still part of a collaborative playlist.
|
||||
</p>
|
||||
|
||||
<section class="collaborators">
|
||||
<h2>
|
||||
<span>Current Collaborators</span>
|
||||
<span v-if="canManageCollaborators">
|
||||
<Btn v-if="shouldShowInviteButton" green small @click.prevent="inviteCollaborators">Invite</Btn>
|
||||
<span v-if="justCreatedInviteLink" class="text-secondary copied">
|
||||
<Icon :icon="faCheckCircle" class="text-green" />
|
||||
Invite link copied to clipboard!
|
||||
</span>
|
||||
<Icon v-if="creatingInviteLink" :icon="faCircleNotch" class="text-green" spin />
|
||||
</span>
|
||||
</h2>
|
||||
|
||||
<ul v-koel-overflow-fade>
|
||||
<li v-for="user in collaborators" :key="user.id">
|
||||
<span class="avatar">
|
||||
<UserAvatar :user="user" width="32" />
|
||||
</span>
|
||||
<span class="name">
|
||||
{{ user.name }}
|
||||
<Icon
|
||||
v-if="user.id === currentUser.id"
|
||||
:icon="faCircleCheck"
|
||||
class="you text-highlight"
|
||||
title="This is you!"
|
||||
/>
|
||||
</span>
|
||||
<span class="role text-secondary">
|
||||
<span v-if="user.id === playlist.user_id" class="owner">Owner</span>
|
||||
<span v-else class="contributor">Contributor</span>
|
||||
</span>
|
||||
<span v-if="canManageCollaborators" class="actions">
|
||||
<Btn v-if="user.id !== playlist.user_id" small red @click.prevent="removeCollaborator(user)">
|
||||
Remove
|
||||
</Btn>
|
||||
</span>
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<footer>
|
||||
<Btn @click.prevent="close">Close</Btn>
|
||||
</footer>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { faCheckCircle, faCircleCheck, faCircleNotch } from '@fortawesome/free-solid-svg-icons'
|
||||
import { sortBy } from 'lodash'
|
||||
import { computed, onMounted, ref, Ref } from 'vue'
|
||||
import { copyText, eventBus, logger } from '@/utils'
|
||||
import { playlistCollaborationService } from '@/services'
|
||||
import { useAuthorization, useModal, useDialogBox } from '@/composables'
|
||||
|
||||
import UserAvatar from '@/components/user/UserAvatar.vue'
|
||||
import Btn from '@/components/ui/Btn.vue'
|
||||
|
||||
const playlist = useModal().getFromContext<Playlist>('playlist')
|
||||
const { currentUser } = useAuthorization()
|
||||
const { showConfirmDialog } = useDialogBox()
|
||||
|
||||
let collaborators: Ref<PlaylistCollaborator[]> = ref([])
|
||||
const creatingInviteLink = ref(false)
|
||||
const justCreatedInviteLink = ref(false)
|
||||
const shouldShowInviteButton = computed(() => !creatingInviteLink.value && !justCreatedInviteLink.value)
|
||||
|
||||
const canManageCollaborators = computed(() => currentUser.value?.id === playlist.user_id)
|
||||
|
||||
const emit = defineEmits<{ (e: 'close'): void }>()
|
||||
const close = () => emit('close')
|
||||
|
||||
const fetchCollaborators = async () => {
|
||||
collaborators.value = sortBy(
|
||||
await playlistCollaborationService.getCollaborators(playlist),
|
||||
({ id }) => {
|
||||
if (id === currentUser.value.id) return 0
|
||||
if (id === playlist.user_id) return 1
|
||||
return 2
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
const inviteCollaborators = async () => {
|
||||
creatingInviteLink.value = true
|
||||
|
||||
try {
|
||||
await copyText(await playlistCollaborationService.createInviteLink(playlist))
|
||||
justCreatedInviteLink.value = true
|
||||
setTimeout(() => (justCreatedInviteLink.value = false), 5_000)
|
||||
} finally {
|
||||
creatingInviteLink.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const removeCollaborator = async (collaborator: PlaylistCollaborator) => {
|
||||
const deadSure = await showConfirmDialog(
|
||||
`Remove ${collaborator.name} as a collaborator? This will remove their contributions as well.`
|
||||
)
|
||||
|
||||
if (!deadSure) return
|
||||
|
||||
try {
|
||||
collaborators.value = collaborators.value.filter(({ id }) => id !== collaborator.id)
|
||||
await playlistCollaborationService.removeCollaborator(playlist, collaborator)
|
||||
eventBus.emit('PLAYLIST_COLLABORATOR_REMOVED', playlist)
|
||||
} catch (e) {
|
||||
logger.error(e)
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => await fetchCollaborators())
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.collaboration-modal {
|
||||
max-width: 640px;
|
||||
}
|
||||
|
||||
h2 {
|
||||
display: flex;
|
||||
|
||||
span:first-child {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.copied {
|
||||
font-size: .95rem;
|
||||
}
|
||||
}
|
||||
|
||||
.collaborators {
|
||||
h2 {
|
||||
font-size: 1.2rem;
|
||||
margin: 1.5rem 0;
|
||||
}
|
||||
|
||||
ul {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
flex-direction: column;
|
||||
margin: 1rem 0;
|
||||
gap: .5rem;
|
||||
max-height: calc(100vh - 8rem);
|
||||
overflow-y: auto;
|
||||
|
||||
li {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
gap: 1rem;
|
||||
background: var(--color-bg-secondary);
|
||||
border: 1px solid var(--color-bg-secondary);
|
||||
padding: .5rem .8rem;
|
||||
border-radius: 5px;
|
||||
transition: border-color .2s ease-in-out;
|
||||
|
||||
&:hover {
|
||||
border-color: rgba(255, 255, 255, .15);
|
||||
}
|
||||
|
||||
.you {
|
||||
margin-left: .5rem;
|
||||
}
|
||||
|
||||
span {
|
||||
display: inline-block;
|
||||
min-width: 0;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.name {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.role {
|
||||
text-align: right;
|
||||
flex: 0 0 96px;
|
||||
text-transform: uppercase;
|
||||
|
||||
span {
|
||||
padding: 3px 4px;
|
||||
border-radius: 4px;
|
||||
border: 1px solid rgba(255, 255, 255, .2);
|
||||
}
|
||||
}
|
||||
|
||||
.actions {
|
||||
flex: 0 0 72px;
|
||||
text-align: right;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
|
@ -0,0 +1,48 @@
|
|||
<template>
|
||||
<span>
|
||||
<Btn v-if="shouldShowInviteButton" green small @click.prevent="inviteCollaborators">Invite</Btn>
|
||||
<span v-if="justCreatedInviteLink" class="text-secondary copied">
|
||||
<Icon :icon="faCheckCircle" class="text-green" />
|
||||
Link copied to clipboard!
|
||||
</span>
|
||||
<Icon v-if="creatingInviteLink" :icon="faCircleNotch" class="text-green" spin />
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { faCheckCircle, faCircleNotch } from '@fortawesome/free-solid-svg-icons'
|
||||
import { computed, ref, toRefs } from 'vue'
|
||||
import { copyText } from '@/utils'
|
||||
import { playlistCollaborationService } from '@/services'
|
||||
|
||||
import Btn from '@/components/ui/Btn.vue'
|
||||
|
||||
const props = defineProps<{ playlist: Playlist }>()
|
||||
const { playlist } = toRefs(props)
|
||||
|
||||
const creatingInviteLink = ref(false)
|
||||
const justCreatedInviteLink = ref(false)
|
||||
const shouldShowInviteButton = computed(() => !creatingInviteLink.value && !justCreatedInviteLink.value)
|
||||
|
||||
const inviteCollaborators = async () => {
|
||||
creatingInviteLink.value = true
|
||||
|
||||
try {
|
||||
await copyText(await playlistCollaborationService.createInviteLink(playlist.value))
|
||||
justCreatedInviteLink.value = true
|
||||
setTimeout(() => (justCreatedInviteLink.value = false), 5_000)
|
||||
} finally {
|
||||
creatingInviteLink.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.copied {
|
||||
font-size: .95rem;
|
||||
}
|
||||
|
||||
svg {
|
||||
margin-right: .25rem;
|
||||
}
|
||||
</style>
|
|
@ -0,0 +1,29 @@
|
|||
import { expect, it } from 'vitest'
|
||||
import { screen, waitFor } from '@testing-library/vue'
|
||||
import UnitTestCase from '@/__tests__/UnitTestCase'
|
||||
import factory from '@/__tests__/factory'
|
||||
import { playlistCollaborationService } from '@/services'
|
||||
import Component from './InvitePlaylistCollaborators.vue'
|
||||
|
||||
new class extends UnitTestCase {
|
||||
protected test () {
|
||||
it('works', async () => {
|
||||
this.mock(playlistCollaborationService, 'createInviteLink').mockResolvedValue('http://localhost:3000/invite/1234')
|
||||
const playlist = factory<Playlist>('playlist')
|
||||
|
||||
this.render(Component, {
|
||||
props: {
|
||||
playlist
|
||||
}
|
||||
})
|
||||
|
||||
this.render(Component)
|
||||
await this.user.click(screen.getByText('Invite'))
|
||||
|
||||
await waitFor(async () => {
|
||||
expect(navigator.clipboard.readText()).equal('http://localhost:3000/invite/1234')
|
||||
screen.getByText('Link copied to clipboard!')
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
|
@ -0,0 +1,26 @@
|
|||
import { it, expect } from 'vitest'
|
||||
import factory from '@/__tests__/factory'
|
||||
import UnitTestCase from '@/__tests__/UnitTestCase'
|
||||
import { ref } from 'vue'
|
||||
import { ModalContextKey } from '@/symbols'
|
||||
import Modal from './PlaylistCollaborationModal.vue'
|
||||
|
||||
new class extends UnitTestCase {
|
||||
protected test () {
|
||||
it ('renders the modal', async () => {
|
||||
const { html } = this.render(Modal, {
|
||||
global: {
|
||||
provide: {
|
||||
[<symbol>ModalContextKey]: [ref({ playlist: factory<Playlist>('playlist') })]
|
||||
},
|
||||
stubs: {
|
||||
InviteCollaborators: this.stub('InviteCollaborators'),
|
||||
CollaboratorList: this.stub('CollaboratorList')
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
expect(html()).toMatchSnapshot()
|
||||
})
|
||||
}
|
||||
}
|
|
@ -0,0 +1,70 @@
|
|||
<template>
|
||||
<div class="collaboration-modal" tabindex="0" @keydown.esc="close">
|
||||
<header>
|
||||
<h1>Playlist Collaboration</h1>
|
||||
</header>
|
||||
|
||||
<main>
|
||||
<p class="intro text-secondary">
|
||||
Collaborative playlists allow multiple users to contribute. <br>
|
||||
Please note that songs added to a collaborative playlist are made accessible to all users,
|
||||
and you cannot mark a song as private if it’s still part of a collaborative playlist.
|
||||
</p>
|
||||
|
||||
<section class="collaborators">
|
||||
<h2>
|
||||
<span>Current Collaborators</span>
|
||||
<InviteCollaborators v-if="canManageCollaborators" :playlist="playlist" />
|
||||
</h2>
|
||||
<div v-koel-overflow-fade class="collaborators-wrapper">
|
||||
<CollaboratorList :playlist="playlist" />
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<footer>
|
||||
<Btn @click.prevent="close">Close</Btn>
|
||||
</footer>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, Ref } from 'vue'
|
||||
import { useAuthorization, useModal, useDialogBox } from '@/composables'
|
||||
|
||||
import Btn from '@/components/ui/Btn.vue'
|
||||
import InviteCollaborators from '@/components/playlist/InvitePlaylistCollaborators.vue'
|
||||
import CollaboratorList from '@/components/playlist/PlaylistCollaboratorList.vue'
|
||||
|
||||
const playlist = useModal().getFromContext<Playlist>('playlist')
|
||||
const { currentUser } = useAuthorization()
|
||||
const { showConfirmDialog } = useDialogBox()
|
||||
|
||||
let collaborators: Ref<PlaylistCollaborator[]> = ref([])
|
||||
|
||||
const canManageCollaborators = computed(() => currentUser.value?.id === playlist.user_id)
|
||||
|
||||
const emit = defineEmits<{ (e: 'close'): void }>()
|
||||
const close = () => emit('close')
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.collaboration-modal {
|
||||
max-width: 640px;
|
||||
}
|
||||
|
||||
h2 {
|
||||
display: flex;
|
||||
font-size: 1.2rem;
|
||||
margin: 1.5rem 0;
|
||||
|
||||
span:first-child {
|
||||
flex: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.collaborators-wrapper {
|
||||
max-height: calc(100vh - 8rem);
|
||||
overflow-y: auto;
|
||||
}
|
||||
</style>
|
|
@ -0,0 +1,40 @@
|
|||
import { expect, it } from 'vitest'
|
||||
import UnitTestCase from '@/__tests__/UnitTestCase'
|
||||
import factory from '@/__tests__/factory'
|
||||
import { playlistCollaborationService } from '@/services'
|
||||
import Component from './PlaylistCollaboratorList.vue'
|
||||
|
||||
new class extends UnitTestCase {
|
||||
private async renderComponent (playlist: Playlist) {
|
||||
const rendered = this.render(Component, {
|
||||
props: {
|
||||
playlist
|
||||
},
|
||||
global: {
|
||||
stubs: {
|
||||
ListItem: this.stub('ListItem')
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
await this.tick(2)
|
||||
|
||||
return rendered
|
||||
}
|
||||
|
||||
protected test () {
|
||||
it('renders', async () => {
|
||||
const playlist = factory<Playlist>('playlist', {
|
||||
is_collaborative: true
|
||||
})
|
||||
|
||||
const fetchMock = this.mock(playlistCollaborationService, 'fetchCollaborators').mockResolvedValue(
|
||||
factory<PlaylistCollaborator>('playlist-collaborator', 5)
|
||||
)
|
||||
|
||||
const { html } = await this.be().renderComponent(playlist)
|
||||
expect(fetchMock).toHaveBeenCalledWith(playlist)
|
||||
expect(html()).toMatchSnapshot()
|
||||
})
|
||||
}
|
||||
}
|
|
@ -0,0 +1,81 @@
|
|||
<template>
|
||||
<ListSkeleton v-if="loading" />
|
||||
<ul v-else>
|
||||
<ListItem
|
||||
is="li"
|
||||
v-for="collaborator in collaborators"
|
||||
:role="currentUserIsOwner ? 'owner' : 'contributor'"
|
||||
:manageable="currentUserIsOwner"
|
||||
:removable="currentUserIsOwner && collaborator.id !== playlist.user_id"
|
||||
:collaborator="collaborator"
|
||||
@remove="removeCollaborator(collaborator)"
|
||||
/>
|
||||
</ul>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { sortBy } from 'lodash'
|
||||
import { computed, onMounted, ref, Ref, toRefs } from 'vue'
|
||||
import { useAuthorization, useDialogBox } from '@/composables'
|
||||
import { playlistCollaborationService } from '@/services'
|
||||
import { eventBus, logger } from '@/utils'
|
||||
|
||||
import ListSkeleton from '@/components/ui/skeletons/PlaylistCollaboratorListSkeleton.vue'
|
||||
import ListItem from '@/components/playlist/PlaylistCollaboratorListItem.vue'
|
||||
|
||||
const props = defineProps<{ playlist: Playlist }>()
|
||||
const { playlist } = toRefs(props)
|
||||
|
||||
const { currentUser } = useAuthorization()
|
||||
const { showConfirmDialog } = useDialogBox()
|
||||
|
||||
let collaborators: Ref<PlaylistCollaborator[]> = ref([])
|
||||
const loading = ref(false)
|
||||
|
||||
const currentUserIsOwner = computed(() => currentUser.value?.id === playlist.value.user_id)
|
||||
|
||||
const fetchCollaborators = async () => {
|
||||
loading.value = true
|
||||
|
||||
try {
|
||||
collaborators.value = sortBy(
|
||||
await playlistCollaborationService.fetchCollaborators(playlist.value),
|
||||
({ id }) => {
|
||||
if (id === currentUser.value.id) return 0
|
||||
if (id === playlist.value.user_id) return 1
|
||||
return 2
|
||||
}
|
||||
)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const removeCollaborator = async (collaborator: PlaylistCollaborator) => {
|
||||
const deadSure = await showConfirmDialog(
|
||||
`Remove ${collaborator.name} as a collaborator? This will remove their contributions as well.`
|
||||
)
|
||||
|
||||
if (!deadSure) return
|
||||
|
||||
try {
|
||||
collaborators.value = collaborators.value.filter(({ id }) => id !== collaborator.id)
|
||||
await playlistCollaborationService.removeCollaborator(playlist.value, collaborator)
|
||||
eventBus.emit('PLAYLIST_COLLABORATOR_REMOVED', playlist.value)
|
||||
} catch (e) {
|
||||
logger.error(e)
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => await fetchCollaborators())
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
ul {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
flex-direction: column;
|
||||
margin: 1rem 0;
|
||||
gap: .5rem;
|
||||
}
|
||||
</style>
|
|
@ -0,0 +1,91 @@
|
|||
import { expect, it } from 'vitest'
|
||||
import { screen } from '@testing-library/vue'
|
||||
import UnitTestCase from '@/__tests__/UnitTestCase'
|
||||
import factory from '@/__tests__/factory'
|
||||
import Component from './PlaylistCollaboratorListItem.vue'
|
||||
|
||||
new class extends UnitTestCase {
|
||||
private renderComponent (props: {
|
||||
collaborator: PlaylistCollaborator,
|
||||
removable: boolean,
|
||||
manageable: boolean,
|
||||
role: 'owner' | 'contributor'
|
||||
}) {
|
||||
return this.render(Component, {
|
||||
props,
|
||||
global: {
|
||||
stubs: {
|
||||
UserAvatar: this.stub('UserAvatar')
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
protected test () {
|
||||
it('does not show a badge when current user is not the collaborator', async () => {
|
||||
const currentUser = factory<User>('user')
|
||||
this.be(currentUser).renderComponent({
|
||||
collaborator: factory<PlaylistCollaborator>('playlist-collaborator', { id: currentUser.id + 1 }),
|
||||
removable: true,
|
||||
manageable: true,
|
||||
role: 'owner'
|
||||
})
|
||||
|
||||
expect(screen.queryByTitle('This is you!')).toBeNull()
|
||||
})
|
||||
|
||||
it('shows a badge when current user is the collaborator', async () => {
|
||||
const currentUser = factory<User>('user')
|
||||
this.be(currentUser).renderComponent({
|
||||
collaborator: factory<PlaylistCollaborator>('playlist-collaborator',
|
||||
{
|
||||
id: currentUser.id,
|
||||
name: currentUser.name,
|
||||
avatar: currentUser.avatar
|
||||
}
|
||||
),
|
||||
removable: true,
|
||||
manageable: true,
|
||||
role: 'owner'
|
||||
})
|
||||
|
||||
screen.getByTitle('This is you!')
|
||||
})
|
||||
|
||||
it('shows the role', async () => {
|
||||
const collaborator = factory<PlaylistCollaborator>('playlist-collaborator')
|
||||
|
||||
this.be().renderComponent({
|
||||
collaborator,
|
||||
removable: true,
|
||||
manageable: true,
|
||||
role: 'owner'
|
||||
})
|
||||
|
||||
screen.getByText('Owner')
|
||||
|
||||
this.be().renderComponent({
|
||||
collaborator,
|
||||
removable: true,
|
||||
manageable: true,
|
||||
role: 'contributor'
|
||||
})
|
||||
|
||||
screen.getByText('Contributor')
|
||||
})
|
||||
|
||||
it('emits the remove event when the remove button is clicked', async () => {
|
||||
const collaborator = factory<PlaylistCollaborator>('playlist-collaborator')
|
||||
const { emitted } = this.be().renderComponent({
|
||||
collaborator,
|
||||
removable: true,
|
||||
manageable: true,
|
||||
role: 'owner'
|
||||
})
|
||||
|
||||
await this.user.click(screen.getByRole('button', { name: 'Remove' }))
|
||||
|
||||
expect(emitted('remove')).toBeTruthy()
|
||||
})
|
||||
}
|
||||
}
|
|
@ -0,0 +1,94 @@
|
|||
<template>
|
||||
<li>
|
||||
<span class="avatar">
|
||||
<UserAvatar :user="collaborator" width="32" />
|
||||
</span>
|
||||
<span class="name">
|
||||
{{ collaborator.name }}
|
||||
<Icon
|
||||
v-if="collaborator.id === currentUser.id"
|
||||
:icon="faCircleCheck"
|
||||
class="you text-highlight"
|
||||
title="This is you!"
|
||||
/>
|
||||
</span>
|
||||
<span class="role text-secondary">
|
||||
<span v-if="role === 'owner'" class="owner">Owner</span>
|
||||
<span v-else class="contributor">Contributor</span>
|
||||
</span>
|
||||
<span v-if="manageable" class="actions">
|
||||
<Btn v-if="removable" small red @click.prevent="emit('remove')">Remove</Btn>
|
||||
</span>
|
||||
</li>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { faCircleCheck } from '@fortawesome/free-solid-svg-icons'
|
||||
import { toRefs } from 'vue'
|
||||
|
||||
import Btn from '@/components/ui/Btn.vue'
|
||||
import UserAvatar from '@/components/user/UserAvatar.vue'
|
||||
import { useAuthorization } from '@/composables'
|
||||
|
||||
const props = defineProps<{
|
||||
collaborator: PlaylistCollaborator,
|
||||
removable: boolean,
|
||||
manageable: boolean,
|
||||
role: 'owner' | 'contributor'
|
||||
}>()
|
||||
|
||||
const { collaborator, removable, role } = toRefs(props)
|
||||
const { currentUser } = useAuthorization()
|
||||
|
||||
const emit = defineEmits<{ (e: 'remove'): void }>()
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
li {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
gap: 1rem;
|
||||
background: var(--color-bg-secondary);
|
||||
border: 1px solid var(--color-bg-secondary);
|
||||
padding: .5rem .8rem;
|
||||
border-radius: 5px;
|
||||
transition: border-color .2s ease-in-out;
|
||||
|
||||
&:hover {
|
||||
border-color: rgba(255, 255, 255, .15);
|
||||
}
|
||||
|
||||
.you {
|
||||
margin-left: .5rem;
|
||||
}
|
||||
|
||||
span {
|
||||
display: inline-block;
|
||||
min-width: 0;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.name {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.role {
|
||||
text-align: right;
|
||||
flex: 0 0 104px;
|
||||
text-transform: uppercase;
|
||||
|
||||
span {
|
||||
padding: 3px 4px;
|
||||
border-radius: 4px;
|
||||
border: 1px solid rgba(255, 255, 255, .2);
|
||||
}
|
||||
}
|
||||
|
||||
.actions {
|
||||
flex: 0 0 72px;
|
||||
text-align: right;
|
||||
}
|
||||
}
|
||||
</style>
|
|
@ -5,7 +5,7 @@ import { eventBus } from '@/utils'
|
|||
import factory from '@/__tests__/factory'
|
||||
import { screen, waitFor } from '@testing-library/vue'
|
||||
import { songStore, userStore } from '@/stores'
|
||||
import { playbackService, playlistCollaborationService } from '@/services'
|
||||
import { playbackService } from '@/services'
|
||||
import { queueStore } from '@/stores'
|
||||
import { MessageToasterStub } from '@/__tests__/stubs'
|
||||
import PlaylistContextMenu from './PlaylistContextMenu.vue'
|
||||
|
@ -153,20 +153,15 @@ new class extends UnitTestCase {
|
|||
expect(screen.queryByText('Delete')).toBeNull()
|
||||
})
|
||||
|
||||
it('invites collaborators', async () => {
|
||||
it('opens collaboration form', async () => {
|
||||
this.enablePlusEdition()
|
||||
const playlist = factory<Playlist>('playlist')
|
||||
await this.renderComponent(playlist)
|
||||
const emitMock = this.mock(eventBus, 'emit')
|
||||
|
||||
const createInviteLinkMock = this.mock(playlistCollaborationService, 'createInviteLink')
|
||||
.mockResolvedValue('https://koel.app/invite/123')
|
||||
await this.user.click(screen.getByText('Collaborate…'))
|
||||
|
||||
await this.user.click(screen.getByText('Invite Collaborators'))
|
||||
|
||||
await waitFor(async () => {
|
||||
expect(createInviteLinkMock).toHaveBeenCalledWith(playlist)
|
||||
expect(await navigator.clipboard.readText()).equal('https://koel.app/invite/123')
|
||||
})
|
||||
expect(emitMock).toHaveBeenCalledWith('MODAL_SHOW_PLAYLIST_COLLABORATION', playlist)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,19 @@
|
|||
// Vitest Snapshot v1
|
||||
|
||||
exports[`renders the modal 1`] = `
|
||||
<div data-v-886145d2="" class="collaboration-modal" tabindex="0">
|
||||
<header data-v-886145d2="">
|
||||
<h1 data-v-886145d2="">Playlist Collaboration</h1>
|
||||
</header>
|
||||
<main data-v-886145d2="">
|
||||
<p data-v-886145d2="" class="intro text-secondary"> Collaborative playlists allow multiple users to contribute. <br data-v-886145d2=""> Please note that songs added to a collaborative playlist are made accessible to all users, and you cannot mark a song as private if it’s still part of a collaborative playlist. </p>
|
||||
<section data-v-886145d2="" class="collaborators">
|
||||
<h2 data-v-886145d2=""><span data-v-886145d2="">Current Collaborators</span>
|
||||
<!--v-if-->
|
||||
</h2>
|
||||
<div data-v-886145d2=""><br data-v-886145d2="" data-testid="CollaboratorList" playlist="[object Object]"></div>
|
||||
</section>
|
||||
</main>
|
||||
<footer data-v-886145d2=""><button data-v-e368fe26="" data-v-886145d2="">Close</button></footer>
|
||||
</div>
|
||||
`;
|
|
@ -0,0 +1,3 @@
|
|||
// Vitest Snapshot v1
|
||||
|
||||
exports[`renders 1`] = `<ul data-v-fa3dce2e=""><br data-v-fa3dce2e="" data-testid="ListItem" is="li" role="contributor" manageable="false" removable="false" collaborator="[object Object]"><br data-v-fa3dce2e="" data-testid="ListItem" is="li" role="contributor" manageable="false" removable="false" collaborator="[object Object]"><br data-v-fa3dce2e="" data-testid="ListItem" is="li" role="contributor" manageable="false" removable="false" collaborator="[object Object]"><br data-v-fa3dce2e="" data-testid="ListItem" is="li" role="contributor" manageable="false" removable="false" collaborator="[object Object]"><br data-v-fa3dce2e="" data-testid="ListItem" is="li" role="contributor" manageable="false" removable="false" collaborator="[object Object]"></ul>`;
|
|
@ -76,7 +76,7 @@ import { usePlaylistManagement, useRouter, useSongList, useAuthorization, useSon
|
|||
import ScreenHeader from '@/components/ui/ScreenHeader.vue'
|
||||
import ScreenEmptyState from '@/components/ui/ScreenEmptyState.vue'
|
||||
import SongListSkeleton from '@/components/ui/skeletons/SongListSkeleton.vue'
|
||||
import CollaboratorsBadge from '@/components/playlist/CollaboratorsBadge.vue'
|
||||
import CollaboratorsBadge from '@/components/playlist/PlaylistCollaboratorsBadge.vue'
|
||||
|
||||
const { currentUser } = useAuthorization()
|
||||
const { triggerNotFound, getRouteParam, onScreenActivated } = useRouter()
|
||||
|
@ -124,7 +124,7 @@ const fetchDetails = async (refresh = false) => {
|
|||
try {
|
||||
[songs.value, collaborators.value] = await Promise.all([
|
||||
songStore.fetchForPlaylist(playlist.value!, refresh),
|
||||
playlistCollaborationService.getCollaborators(playlist.value!),
|
||||
playlistCollaborationService.fetchCollaborators(playlist.value!),
|
||||
])
|
||||
|
||||
sort()
|
||||
|
|
|
@ -0,0 +1,22 @@
|
|||
<template>
|
||||
<div class="wrapper skeleton">
|
||||
<div v-for="i in 3" :key="i" class="pulse"/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.wrapper {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
gap: .5rem;
|
||||
|
||||
> div {
|
||||
border-radius: 5px;
|
||||
height: 49px;
|
||||
}
|
||||
}
|
||||
</style>
|
|
@ -0,0 +1,60 @@
|
|||
import { expect, it } from 'vitest'
|
||||
import UnitTestCase from '@/__tests__/UnitTestCase'
|
||||
import factory from '@/__tests__/factory'
|
||||
import { cache, http } from '@/services'
|
||||
import { playlistCollaborationService as service } from './playlistCollaborationService'
|
||||
|
||||
new class extends UnitTestCase {
|
||||
protected test () {
|
||||
it('creates invite link', async () => {
|
||||
const playlist = factory<Playlist>('playlist', { is_smart: false })
|
||||
const postMock = this.mock(http, 'post').mockResolvedValue({ token: 'abc123' })
|
||||
|
||||
const link = await service.createInviteLink(playlist)
|
||||
|
||||
expect(postMock).toHaveBeenCalledWith(`playlists/${playlist.id}/collaborators/invite`)
|
||||
expect(link).toBe('http://localhost:3000/#/playlist/collaborate/abc123')
|
||||
})
|
||||
|
||||
it('throws if trying to create invite link for smart playlist', async () => {
|
||||
const playlist = factory<Playlist>('playlist', { is_smart: true })
|
||||
|
||||
await expect(service.createInviteLink(playlist)).rejects.toThrow('Smart playlists are not collaborative.')
|
||||
})
|
||||
|
||||
it('accepts invite', async () => {
|
||||
const postMock = this.mock(http, 'post').mockResolvedValue({})
|
||||
|
||||
await service.acceptInvite('abc123')
|
||||
|
||||
expect(postMock).toHaveBeenCalledWith(`playlists/collaborators/accept`, { token: 'abc123' })
|
||||
})
|
||||
|
||||
it('fetches collaborators', async () => {
|
||||
const playlist = factory<Playlist>('playlist')
|
||||
const collaborators = factory<PlaylistCollaborator[]>('playlist-collaborator', 2)
|
||||
const getMock = this.mock(http, 'get').mockResolvedValue(collaborators)
|
||||
|
||||
const received = await service.fetchCollaborators(playlist)
|
||||
|
||||
expect(getMock).toHaveBeenCalledWith(`playlists/${playlist.id}/collaborators`)
|
||||
expect(received).toBe(collaborators)
|
||||
})
|
||||
|
||||
it('removes collaborator', async () => {
|
||||
const playlist = factory<Playlist>('playlist')
|
||||
const collaborator = factory<PlaylistCollaborator>('playlist-collaborator')
|
||||
const deleteMock = this.mock(http, 'delete').mockResolvedValue({})
|
||||
const removeCacheMock = this.mock(cache, 'remove')
|
||||
|
||||
await service.removeCollaborator(playlist, collaborator)
|
||||
|
||||
expect(deleteMock).toHaveBeenCalledWith(`playlists/${playlist.id}/collaborators`, {
|
||||
collaborator:
|
||||
collaborator.id
|
||||
})
|
||||
|
||||
expect(removeCacheMock).toHaveBeenCalledWith(['playlist.songs', playlist.id])
|
||||
})
|
||||
}
|
||||
}
|
|
@ -14,7 +14,7 @@ export const playlistCollaborationService = {
|
|||
return http.post<Playlist>(`playlists/collaborators/accept`, { token })
|
||||
},
|
||||
|
||||
async getCollaborators (playlist: Playlist) {
|
||||
async fetchCollaborators (playlist: Playlist) {
|
||||
return http.get<PlaylistCollaborator[]>(`playlists/${playlist.id}/collaborators`)
|
||||
},
|
||||
|
||||
|
|
16
resources/assets/js/services/plusService.spec.ts
Normal file
16
resources/assets/js/services/plusService.spec.ts
Normal file
|
@ -0,0 +1,16 @@
|
|||
import { expect, it } from 'vitest'
|
||||
import UnitTestCase from '@/__tests__/UnitTestCase'
|
||||
import { http } from '@/services'
|
||||
import { plusService as service } from './plusService'
|
||||
|
||||
new class extends UnitTestCase {
|
||||
protected test () {
|
||||
it('activates license', async () => {
|
||||
const postMock = this.mock(http, 'post').mockResolvedValue({})
|
||||
|
||||
await service.activateLicense('abc123')
|
||||
|
||||
expect(postMock).toHaveBeenCalledWith('licenses/activate', { key: 'abc123' })
|
||||
})
|
||||
}
|
||||
}
|
4
resources/assets/js/types.d.ts
vendored
4
resources/assets/js/types.d.ts
vendored
|
@ -223,7 +223,9 @@ interface PlaylistFolder {
|
|||
// we don't need to keep track of the playlists here, as they can be computed using their folder_id value
|
||||
}
|
||||
|
||||
type PlaylistCollaborator = Pick<User, 'id' | 'name' | 'avatar'>
|
||||
type PlaylistCollaborator = Pick<User, 'id' | 'name' | 'avatar'> & {
|
||||
type: 'playlist-collaborators'
|
||||
}
|
||||
|
||||
interface Playlist {
|
||||
type: 'playlists'
|
||||
|
|
Loading…
Reference in a new issue