mirror of
https://github.com/koel/koel
synced 2024-09-20 06:11:53 +00:00
feat: tabs for Artist/Album screens (#1532)
This commit is contained in:
parent
628cb7a4f4
commit
41f6abc087
33 changed files with 402 additions and 117 deletions
20
app/Http/Controllers/V6/API/ArtistAlbumController.php
Normal file
20
app/Http/Controllers/V6/API/ArtistAlbumController.php
Normal file
|
@ -0,0 +1,20 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Controllers\V6\API;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Resources\AlbumResource;
|
||||
use App\Models\Artist;
|
||||
use App\Repositories\AlbumRepository;
|
||||
|
||||
class ArtistAlbumController extends Controller
|
||||
{
|
||||
public function __construct(private AlbumRepository $albumRepository)
|
||||
{
|
||||
}
|
||||
|
||||
public function index(Artist $artist)
|
||||
{
|
||||
return AlbumResource::collection($this->albumRepository->getByArtist($artist));
|
||||
}
|
||||
}
|
|
@ -3,6 +3,7 @@
|
|||
namespace App\Repositories;
|
||||
|
||||
use App\Models\Album;
|
||||
use App\Models\Artist;
|
||||
use App\Models\User;
|
||||
use App\Repositories\Traits\Searchable;
|
||||
use Illuminate\Contracts\Pagination\Paginator;
|
||||
|
@ -52,6 +53,16 @@ class AlbumRepository extends Repository
|
|||
->get();
|
||||
}
|
||||
|
||||
/** @return Collection|array<array-key, Album> */
|
||||
public function getByArtist(Artist $artist): Collection
|
||||
{
|
||||
return Album::query()
|
||||
->where('artist_id', $artist->id)
|
||||
->orWhereIn('id', $artist->songs()->pluck('album_id'))
|
||||
->orderBy('name')
|
||||
->get();
|
||||
}
|
||||
|
||||
public function paginate(): Paginator
|
||||
{
|
||||
return Album::query()
|
||||
|
|
|
@ -28,7 +28,6 @@
|
|||
>
|
||||
Shuffle
|
||||
</a>
|
||||
•
|
||||
<a
|
||||
v-if="allowDownload"
|
||||
:title="`Download all songs in the album ${album.name}`"
|
||||
|
|
|
@ -3,7 +3,7 @@ import factory from '@/__tests__/factory'
|
|||
import UnitTestCase from '@/__tests__/UnitTestCase'
|
||||
import { commonStore, songStore } from '@/stores'
|
||||
import { fireEvent } from '@testing-library/vue'
|
||||
import { playbackService, mediaInfoService } from '@/services'
|
||||
import { mediaInfoService, playbackService } from '@/services'
|
||||
import AlbumInfoComponent from './AlbumInfo.vue'
|
||||
|
||||
let album: Album
|
||||
|
@ -26,7 +26,8 @@ new class extends UnitTestCase {
|
|||
},
|
||||
global: {
|
||||
stubs: {
|
||||
TrackList: this.stub()
|
||||
TrackList: this.stub(),
|
||||
AlbumThumbnail: this.stub('thumbnail')
|
||||
}
|
||||
}
|
||||
})
|
||||
|
@ -39,11 +40,16 @@ new class extends UnitTestCase {
|
|||
|
||||
protected test () {
|
||||
it.each<[MediaInfoDisplayMode]>([['aside'], ['full']])('renders in %s mode', async (mode) => {
|
||||
const { getByTestId } = await this.renderComponent(mode)
|
||||
const { getByTestId, queryByTestId } = await this.renderComponent(mode)
|
||||
|
||||
getByTestId('album-artist-thumbnail')
|
||||
getByTestId('album-info-tracks')
|
||||
|
||||
if (mode === 'aside') {
|
||||
getByTestId('thumbnail')
|
||||
} else {
|
||||
expect(queryByTestId('thumbnail')).toBeNull()
|
||||
}
|
||||
|
||||
expect(getByTestId('album-info').classList.contains(mode)).toBe(true)
|
||||
})
|
||||
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
<template>
|
||||
<article :class="mode" class="album-info" data-testid="album-info">
|
||||
<h1 class="name">
|
||||
<h1 v-if="mode === 'aside'" class="name">
|
||||
<span>{{ album.name }}</span>
|
||||
<button :title="`Play all songs in ${album.name}`" class="control" type="button" @click.prevent="play">
|
||||
<icon :icon="faCirclePlay" size="xl"/>
|
||||
|
@ -8,7 +8,7 @@
|
|||
</h1>
|
||||
|
||||
<main>
|
||||
<AlbumThumbnail :entity="album"/>
|
||||
<AlbumThumbnail v-if="mode === 'aside'" :entity="album"/>
|
||||
|
||||
<template v-if="info">
|
||||
<div v-if="info.wiki?.summary" class="wiki">
|
||||
|
@ -36,7 +36,7 @@ import { faCirclePlay } from '@fortawesome/free-solid-svg-icons'
|
|||
import { computed, defineAsyncComponent, ref, toRefs, watch } from 'vue'
|
||||
import { useThirdPartyServices } from '@/composables'
|
||||
import { songStore } from '@/stores'
|
||||
import { playbackService, mediaInfoService } from '@/services'
|
||||
import { mediaInfoService, playbackService } from '@/services'
|
||||
import { RouterKey } from '@/symbols'
|
||||
|
||||
import AlbumThumbnail from '@/components/ui/AlbumArtistThumbnail.vue'
|
||||
|
@ -75,6 +75,10 @@ const play = async () => {
|
|||
|
||||
.track-listing {
|
||||
margin-top: 2rem;
|
||||
|
||||
::v-deep(h1) {
|
||||
margin-bottom: 1.2rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
|
@ -3,7 +3,7 @@
|
|||
exports[`renders 1`] = `
|
||||
<article class="full item" title="IV by Led Zeppelin" data-testid="album-card" draggable="true" tabindex="0" data-v-b204153b=""><span class="cover" data-testid="album-artist-thumbnail" data-v-e37470a2="" data-v-b204153b=""><a class="control control-play" href="" role="button" data-v-e37470a2=""><span class="hidden" data-v-e37470a2="">Play all songs in the album IV</span><span class="icon" data-v-e37470a2=""></span></a></span>
|
||||
<footer data-v-b204153b=""><a href="#/album/42" class="name" data-testid="name" data-v-b204153b="">IV</a><a href="#/artist/17" class="artist" data-v-b204153b="">Led Zeppelin</a>
|
||||
<p class="meta" data-v-b204153b=""><a title="Shuffle all songs in the album IV" class="shuffle-album" data-testid="shuffle-album" href="" role="button" data-v-b204153b=""> Shuffle </a> • <a title="Download all songs in the album IV" class="download-album" data-testid="download-album" href="" role="button" data-v-b204153b=""> Download </a></p>
|
||||
<p class="meta" data-v-b204153b=""><a title="Shuffle all songs in the album IV" class="shuffle-album" data-testid="shuffle-album" href="" role="button" data-v-b204153b=""> Shuffle </a><a title="Download all songs in the album IV" class="download-album" data-testid="download-album" href="" role="button" data-v-b204153b=""> Download </a></p>
|
||||
</footer>
|
||||
</article>
|
||||
`;
|
||||
|
|
|
@ -28,7 +28,6 @@
|
|||
>
|
||||
Shuffle
|
||||
</a>
|
||||
•
|
||||
<a
|
||||
v-if="allowDownload"
|
||||
:title="`Download all songs by ${artist.name}`"
|
||||
|
|
|
@ -23,6 +23,11 @@ new class extends UnitTestCase {
|
|||
props: {
|
||||
artist,
|
||||
mode
|
||||
},
|
||||
global: {
|
||||
stubs: {
|
||||
ArtistThumbnail: this.stub('thumbnail')
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
|
@ -34,9 +39,13 @@ new class extends UnitTestCase {
|
|||
|
||||
protected test () {
|
||||
it.each<[MediaInfoDisplayMode]>([['aside'], ['full']])('renders in %s mode', async (mode) => {
|
||||
const { getByTestId } = await this.renderComponent(mode)
|
||||
const { getByTestId, queryByTestId } = await this.renderComponent(mode)
|
||||
|
||||
getByTestId('album-artist-thumbnail')
|
||||
if (mode === 'aside') {
|
||||
getByTestId('thumbnail')
|
||||
} else {
|
||||
expect(queryByTestId('thumbnail'))
|
||||
}
|
||||
|
||||
expect(getByTestId('artist-info').classList.contains(mode)).toBe(true)
|
||||
})
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
<template>
|
||||
<article :class="mode" class="artist-info" data-testid="artist-info">
|
||||
<h1 class="name">
|
||||
<h1 v-if="mode === 'aside'" class="name">
|
||||
<span>{{ artist.name }}</span>
|
||||
<button :title="`Play all songs by ${artist.name}`" class="control" type="button" @click.prevent="play">
|
||||
<icon :icon="faCirclePlay" size="xl"/>
|
||||
|
@ -8,7 +8,7 @@
|
|||
</h1>
|
||||
|
||||
<main>
|
||||
<ArtistThumbnail :entity="artist"/>
|
||||
<ArtistThumbnail v-if="mode === 'aside'" :entity="artist"/>
|
||||
|
||||
<template v-if="info">
|
||||
<div v-if="info.bio?.summary" class="bio">
|
||||
|
@ -32,7 +32,7 @@
|
|||
<script lang="ts" setup>
|
||||
import { faCirclePlay } from '@fortawesome/free-solid-svg-icons'
|
||||
import { computed, ref, toRefs, watch } from 'vue'
|
||||
import { playbackService, mediaInfoService } from '@/services'
|
||||
import { mediaInfoService, playbackService } from '@/services'
|
||||
import { useThirdPartyServices } from '@/composables'
|
||||
import { songStore } from '@/stores'
|
||||
import { RouterKey } from '@/symbols'
|
||||
|
|
|
@ -4,7 +4,7 @@ exports[`renders 1`] = `
|
|||
<article class="full item" title="Led Zeppelin" data-testid="artist-card" draggable="true" tabindex="0" data-v-85d5de45=""><span class="cover" data-testid="album-artist-thumbnail" data-v-e37470a2="" data-v-85d5de45=""><a class="control control-play" href="" role="button" data-v-e37470a2=""><span class="hidden" data-v-e37470a2="">Play all songs by Led Zeppelin</span><span class="icon" data-v-e37470a2=""></span></a></span>
|
||||
<footer data-v-85d5de45="">
|
||||
<div class="info" data-v-85d5de45=""><a href="#/artist/42" class="name" data-testid="name" data-v-85d5de45="">Led Zeppelin</a></div>
|
||||
<p class="meta" data-v-85d5de45=""><a title="Shuffle all songs by Led Zeppelin" class="shuffle-artist" data-testid="shuffle-artist" href="" role="button" data-v-85d5de45=""> Shuffle </a> • <a title="Download all songs by Led Zeppelin" class="download-artist" data-testid="download-artist" href="" role="button" data-v-85d5de45=""> Download </a></p>
|
||||
<p class="meta" data-v-85d5de45=""><a title="Shuffle all songs by Led Zeppelin" class="shuffle-artist" data-testid="shuffle-artist" href="" role="button" data-v-85d5de45=""> Shuffle </a><a title="Download all songs by Led Zeppelin" class="download-artist" data-testid="download-artist" href="" role="button" data-v-85d5de45=""> Download </a></p>
|
||||
</footer>
|
||||
</article>
|
||||
`;
|
||||
|
|
|
@ -138,6 +138,7 @@ const logout = () => eventBus.emit('LOG_OUT')
|
|||
padding: 2rem 1.7rem;
|
||||
background: var(--color-bg-secondary);
|
||||
overflow: auto;
|
||||
box-shadow: 0 0 5px 0 rgba(0, 0, 0, 0.1);
|
||||
|
||||
@media (hover: none) {
|
||||
// Enable scroll with momentum on touch devices
|
||||
|
@ -169,7 +170,7 @@ const logout = () => eventBus.emit('LOG_OUT')
|
|||
flex-direction: row;
|
||||
padding: .5rem 1rem;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, .05);
|
||||
box-shadow: 0 0 30px 0 rgba(0, 0, 0, .75);
|
||||
box-shadow: 0 0 30px 0 rgba(0, 0, 0, .5);
|
||||
}
|
||||
|
||||
.top, .bottom {
|
||||
|
|
|
@ -82,20 +82,19 @@ onMounted(() => router.resolve())
|
|||
overflow: hidden;
|
||||
|
||||
> section {
|
||||
position: absolute;
|
||||
max-height: 100%;
|
||||
min-height: 100%;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
backface-visibility: hidden;
|
||||
-webkit-backface-visibility: hidden;
|
||||
|
||||
.main-scroll-wrap {
|
||||
&:not(.song-list-wrap) {
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
overflow: scroll;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 1.5rem;
|
||||
|
||||
@supports (scrollbar-gutter: stable) {
|
||||
overflow: auto;
|
||||
|
|
|
@ -155,6 +155,7 @@ nav {
|
|||
overflow: auto;
|
||||
overflow-x: hidden;
|
||||
-ms-overflow-style: -ms-autohiding-scrollbar;
|
||||
box-shadow: 0 0 5px 0 rgba(0, 0, 0, 0.1);
|
||||
|
||||
> * + * {
|
||||
margin-top: 2.25rem;
|
||||
|
|
|
@ -9,7 +9,7 @@ exports[`renders 1`] = `
|
|||
<div class="logo" data-v-6b5b01a9=""><img alt="Koel's logo" src="undefined/resources/assets/img/logo.svg" width="128" data-v-6b5b01a9=""></div>
|
||||
<p class="current-version" data-v-6b5b01a9="">v0.0.0</p>
|
||||
<!--v-if-->
|
||||
<p class="author" data-v-6b5b01a9=""> Made with ❤️ by <a href="https://github.com/phanan" rel="noopener" target="_blank" data-v-6b5b01a9="">Phan An</a> and quite a few <a href="https://github.com/koel/core/graphs/contributors" rel="noopener" target="_blank" data-v-6b5b01a9="">awesome</a> <a href="https://github.com/koel/koel/graphs/contributors" rel="noopener" target="_blank" data-v-6b5b01a9="">contributors</a>. </p>
|
||||
<p class="author" data-v-6b5b01a9=""> Made with ❤️ by <a href="https://github.com/phanan" rel="noopener" target="_blank" data-v-6b5b01a9="">Phan An</a> and quite a few <a href="https://github.com/koel/core/graphs/contributors" rel="noopener" target="_blank" data-v-6b5b01a9="">awesome</a><a href="https://github.com/koel/koel/graphs/contributors" rel="noopener" target="_blank" data-v-6b5b01a9="">contributors</a>. </p>
|
||||
<!--v-if-->
|
||||
<p data-v-6b5b01a9=""> Loving Koel? Please consider supporting its development via <a href="https://github.com/users/phanan/sponsorship" rel="noopener" target="_blank" data-v-6b5b01a9="">GitHub Sponsors</a> and/or <a href="https://opencollective.com/koel" rel="noopener" target="_blank" data-v-6b5b01a9="">OpenCollective</a>. </p>
|
||||
</main>
|
||||
|
|
|
@ -1,11 +1,10 @@
|
|||
import { fireEvent, waitFor } from '@testing-library/vue'
|
||||
import { fireEvent, getByTestId, waitFor } from '@testing-library/vue'
|
||||
import { expect, it } from 'vitest'
|
||||
import factory from '@/__tests__/factory'
|
||||
import UnitTestCase from '@/__tests__/UnitTestCase'
|
||||
import { albumStore, commonStore, songStore } from '@/stores'
|
||||
import { downloadService } from '@/services'
|
||||
import { eventBus } from '@/utils'
|
||||
import CloseModalBtn from '@/components/ui/BtnCloseModal.vue'
|
||||
import AlbumScreen from './AlbumScreen.vue'
|
||||
|
||||
let album: Album
|
||||
|
@ -36,9 +35,9 @@ new class extends UnitTestCase {
|
|||
const rendered = this.render(AlbumScreen, {
|
||||
global: {
|
||||
stubs: {
|
||||
CloseModalBtn,
|
||||
AlbumInfo: this.stub('album-info'),
|
||||
SongList: this.stub('song-list')
|
||||
SongList: this.stub('song-list'),
|
||||
AlbumCard: this.stub('album-card'),
|
||||
AlbumInfo: this.stub('album-info')
|
||||
}
|
||||
}
|
||||
})
|
||||
|
@ -54,17 +53,6 @@ new class extends UnitTestCase {
|
|||
}
|
||||
|
||||
protected test () {
|
||||
it('shows and hides info', async () => {
|
||||
const { getByTitle, getByTestId, queryByTestId, html } = await this.renderComponent()
|
||||
expect(queryByTestId('album-info')).toBeNull()
|
||||
|
||||
await fireEvent.click(getByTitle('View album information'))
|
||||
expect(queryByTestId('album-info')).not.toBeNull()
|
||||
|
||||
await fireEvent.click(getByTestId('close-modal-btn'))
|
||||
expect(queryByTestId('album-info')).toBeNull()
|
||||
})
|
||||
|
||||
it('downloads', async () => {
|
||||
const downloadMock = this.mock(downloadService, 'fromAlbum')
|
||||
const { getByText } = await this.renderComponent()
|
||||
|
@ -86,5 +74,24 @@ new class extends UnitTestCase {
|
|||
expect(goMock).toHaveBeenCalledWith('albums')
|
||||
})
|
||||
})
|
||||
|
||||
it('shows the song list', async () => {
|
||||
const { getByTestId } = await this.renderComponent()
|
||||
getByTestId('song-list')
|
||||
})
|
||||
|
||||
it('shows other albums from the same artist', async () => {
|
||||
const albums = factory<Album>('album', 3)
|
||||
albums.push(album)
|
||||
const fetchMock = this.mock(albumStore, 'fetchForArtist').mockResolvedValue(albums)
|
||||
const { getByLabelText, getAllByTestId } = await this.renderComponent()
|
||||
|
||||
await fireEvent.click(getByLabelText('Other Albums'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(fetchMock).toHaveBeenCalledWith(album.artist_id)
|
||||
expect(getAllByTestId('album-card')).toHaveLength(3) // current album is excluded
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
@ -15,7 +15,6 @@
|
|||
<span v-else class="nope">{{ album.artist_name }}</span>
|
||||
<span>{{ pluralize(songs, 'song') }}</span>
|
||||
<span>{{ duration }}</span>
|
||||
<a v-if="useLastfm" class="info" href title="View album information" @click.prevent="showInfo">Info</a>
|
||||
|
||||
<a
|
||||
v-if="allowDownload"
|
||||
|
@ -37,20 +36,58 @@
|
|||
</template>
|
||||
</ScreenHeader>
|
||||
|
||||
<SongListSkeleton v-if="loading"/>
|
||||
<SongList v-else ref="songList" @sort="sort" @press:enter="onPressEnter" @scroll-breakpoint="onScrollBreakpoint"/>
|
||||
<ScreenTabs>
|
||||
<template #header>
|
||||
<label :class="{ active: activeTab === 'Songs' }">
|
||||
Songs
|
||||
<input type="radio" name="tab" value="Songs" v-model="activeTab"/>
|
||||
</label>
|
||||
<label :class="{ active: activeTab === 'OtherAlbums' }">
|
||||
Other Albums
|
||||
<input type="radio" name="tab" value="OtherAlbums" v-model="activeTab"/>
|
||||
</label>
|
||||
<label :class="{ active: activeTab === 'Info' }" v-if="useLastfm">
|
||||
Information
|
||||
<input type="radio" name="tab" value="Info" v-model="activeTab"/>
|
||||
</label>
|
||||
</template>
|
||||
|
||||
<section v-if="!loading && useLastfm && showingInfo" class="info-wrapper">
|
||||
<CloseModalBtn class="close-modal" @click="showingInfo = false"/>
|
||||
<div class="inner">
|
||||
<div v-show="activeTab === 'Songs'">
|
||||
<SongListSkeleton v-if="loading"/>
|
||||
<SongList
|
||||
v-else
|
||||
ref="songList"
|
||||
@sort="sort"
|
||||
@press:enter="onPressEnter"
|
||||
@scroll-breakpoint="onScrollBreakpoint"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-show="activeTab === 'OtherAlbums'" class="albums-pane" data-testid="albums-pane">
|
||||
<template v-if="otherAlbums">
|
||||
<ul v-if="otherAlbums.length" class="as-list">
|
||||
<li v-for="album in otherAlbums" :key="album.id">
|
||||
<AlbumCard :album="album" layout="compact"/>
|
||||
</li>
|
||||
</ul>
|
||||
<p v-else class="text-secondary">No other albums by {{ album.artist_name }} found in the library.</p>
|
||||
</template>
|
||||
<ul v-else class="as-list">
|
||||
<li v-for="i in 12" :key="i">
|
||||
<AlbumCardSkeleton layout="compact"/>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div v-show="activeTab === 'Info'" class="info-pane" v-if="useLastfm && album">
|
||||
<AlbumInfo :album="album" mode="full"/>
|
||||
</div>
|
||||
</section>
|
||||
</ScreenTabs>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { computed, defineAsyncComponent, onMounted, ref, toRef } from 'vue'
|
||||
import { computed, defineAsyncComponent, onMounted, ref, toRef, watch } from 'vue'
|
||||
import { eventBus, logger, pluralize, requireInjection } from '@/utils'
|
||||
import { albumStore, artistStore, commonStore, songStore } from '@/stores'
|
||||
import { downloadService } from '@/services'
|
||||
|
@ -61,17 +98,24 @@ import ScreenHeader from '@/components/ui/ScreenHeader.vue'
|
|||
import AlbumThumbnail from '@/components/ui/AlbumArtistThumbnail.vue'
|
||||
import ScreenHeaderSkeleton from '@/components/ui/skeletons/ScreenHeaderSkeleton.vue'
|
||||
import SongListSkeleton from '@/components/ui/skeletons/SongListSkeleton.vue'
|
||||
import ScreenTabs from '@/components/ui/ArtistAlbumScreenTabs.vue'
|
||||
|
||||
type Tab = 'Songs' | 'OtherAlbums' | 'Info'
|
||||
const activeTab = ref<Tab>('Songs')
|
||||
|
||||
const AlbumInfo = defineAsyncComponent(() => import('@/components/album/AlbumInfo.vue'))
|
||||
const CloseModalBtn = defineAsyncComponent(() => import('@/components/ui/BtnCloseModal.vue'))
|
||||
const AlbumCard = defineAsyncComponent(() => import('@/components/album/AlbumCard.vue'))
|
||||
const AlbumCardSkeleton = defineAsyncComponent(() => import('@/components/ui/skeletons/ArtistAlbumCardSkeleton.vue'))
|
||||
|
||||
const dialog = requireInjection(DialogBoxKey)
|
||||
const router = requireInjection(RouterKey)
|
||||
|
||||
const albumId = ref<number>()
|
||||
const album = ref<Album>()
|
||||
const songs = ref<Song[]>([])
|
||||
const showingInfo = ref(false)
|
||||
const loading = ref(false)
|
||||
let otherAlbums = ref<Album[]>()
|
||||
let info = ref<ArtistInfo | null>()
|
||||
|
||||
const {
|
||||
SongList,
|
||||
|
@ -98,10 +142,22 @@ const isNormalArtist = computed(() => {
|
|||
})
|
||||
|
||||
const download = () => downloadService.fromAlbum(album.value!)
|
||||
const showInfo = () => (showingInfo.value = true)
|
||||
|
||||
onMounted(async () => {
|
||||
const id = parseInt(router.$currentRoute.value.params!.id)
|
||||
watch(activeTab, async tab => {
|
||||
if (tab === 'OtherAlbums' && !otherAlbums.value) {
|
||||
const albums = await albumStore.fetchForArtist(album.value!.artist_id)
|
||||
otherAlbums.value = albums.filter(a => a.id !== album.value!.id)
|
||||
}
|
||||
})
|
||||
|
||||
watch(albumId, async id => {
|
||||
if (!id) return
|
||||
|
||||
album.value = undefined
|
||||
info.value = undefined
|
||||
otherAlbums.value = undefined
|
||||
activeTab.value = 'Songs'
|
||||
|
||||
loading.value = true
|
||||
|
||||
try {
|
||||
|
@ -111,20 +167,36 @@ onMounted(async () => {
|
|||
])
|
||||
|
||||
sort('track')
|
||||
} catch (e) {
|
||||
logger.error(e)
|
||||
} catch (error) {
|
||||
logger.error(error)
|
||||
dialog.value.error('Failed to load album. Please try again.')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
})
|
||||
|
||||
onMounted(async () => (albumId.value = parseInt(router.$currentRoute.value.params!.id)))
|
||||
|
||||
router.onRouteChanged(route => route.screen === 'Album' && (albumId.value = parseInt(route.params!.id)))
|
||||
|
||||
// if the current album has been deleted, go back to the list
|
||||
eventBus.on('SONGS_UPDATED', () => albumStore.byId(album.value!.id) || router.go('albums'))
|
||||
eventBus.on('SONGS_UPDATED', () => albumStore.byId(albumId.value) || router.go('albums'))
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
#albumWrapper {
|
||||
@include artist-album-info-wrapper();
|
||||
}
|
||||
|
||||
.albums-pane {
|
||||
padding: 1.8rem;
|
||||
|
||||
> ul {
|
||||
@include artist-album-wrapper();
|
||||
}
|
||||
}
|
||||
|
||||
.info-pane {
|
||||
padding: 1.8rem;
|
||||
}
|
||||
</style>
|
||||
|
|
|
@ -5,7 +5,6 @@ import UnitTestCase from '@/__tests__/UnitTestCase'
|
|||
import { artistStore, commonStore, songStore } from '@/stores'
|
||||
import { downloadService } from '@/services'
|
||||
import { eventBus } from '@/utils'
|
||||
import CloseModalBtn from '@/components/ui/BtnCloseModal.vue'
|
||||
import ArtistScreen from './ArtistScreen.vue'
|
||||
|
||||
let artist: Artist
|
||||
|
@ -35,9 +34,9 @@ new class extends UnitTestCase {
|
|||
const rendered = this.render(ArtistScreen, {
|
||||
global: {
|
||||
stubs: {
|
||||
CloseModalBtn,
|
||||
ArtistInfo: this.stub('artist-info'),
|
||||
SongList: this.stub('song-list')
|
||||
SongList: this.stub('song-list'),
|
||||
AlbumCard: this.stub('album-card')
|
||||
}
|
||||
}
|
||||
})
|
||||
|
@ -53,17 +52,6 @@ new class extends UnitTestCase {
|
|||
}
|
||||
|
||||
protected test () {
|
||||
it('shows and hides info', async () => {
|
||||
const { getByTitle, getByTestId, queryByTestId } = await this.renderComponent()
|
||||
expect(queryByTestId('artist-info')).toBeNull()
|
||||
|
||||
await fireEvent.click(getByTitle('View artist information'))
|
||||
expect(queryByTestId('artist-info')).not.toBeNull()
|
||||
|
||||
await fireEvent.click(getByTestId('close-modal-btn'))
|
||||
expect(queryByTestId('artist-info')).toBeNull()
|
||||
})
|
||||
|
||||
it('downloads', async () => {
|
||||
const downloadMock = this.mock(downloadService, 'fromArtist')
|
||||
const { getByText } = await this.renderComponent()
|
||||
|
@ -85,5 +73,10 @@ new class extends UnitTestCase {
|
|||
expect(goMock).toHaveBeenCalledWith('artists')
|
||||
})
|
||||
})
|
||||
|
||||
it('shows the song list', async () => {
|
||||
const { getByTestId } = await this.renderComponent()
|
||||
getByTestId('song-list')
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
@ -14,7 +14,6 @@
|
|||
<span>{{ pluralize(albumCount, 'album') }}</span>
|
||||
<span>{{ pluralize(songs, 'song') }}</span>
|
||||
<span>{{ duration }}</span>
|
||||
<a v-if="useLastfm" class="info" href title="View artist information" @click.prevent="showInfo">Info</a>
|
||||
|
||||
<a
|
||||
v-if="allowDownload"
|
||||
|
@ -37,22 +36,57 @@
|
|||
</template>
|
||||
</ScreenHeader>
|
||||
|
||||
<SongListSkeleton v-if="loading"/>
|
||||
<SongList v-else ref="songList" @sort="sort" @press:enter="onPressEnter" @scroll-breakpoint="onScrollBreakpoint"/>
|
||||
<ScreenTabs>
|
||||
<template #header>
|
||||
<label :class="{ active: activeTab === 'Songs' }">
|
||||
Songs
|
||||
<input type="radio" name="tab" value="Songs" v-model="activeTab"/>
|
||||
</label>
|
||||
<label :class="{ active: activeTab === 'Albums' }">
|
||||
Albums
|
||||
<input type="radio" name="tab" value="Albums" v-model="activeTab"/>
|
||||
</label>
|
||||
<label :class="{ active: activeTab === 'Info' }" v-if="useLastfm">
|
||||
Information
|
||||
<input type="radio" name="tab" value="Info" v-model="activeTab"/>
|
||||
</label>
|
||||
</template>
|
||||
|
||||
<section v-if="!loading && useLastfm && showingInfo" class="info-wrapper">
|
||||
<CloseModalBtn class="close-modal" @click="showingInfo = false"/>
|
||||
<div class="inner">
|
||||
<div v-show="activeTab === 'Songs'" class="songs-pane">
|
||||
<SongListSkeleton v-if="loading"/>
|
||||
<SongList
|
||||
v-else
|
||||
ref="songList"
|
||||
@sort="sort"
|
||||
@press:enter="onPressEnter"
|
||||
@scroll-breakpoint="onScrollBreakpoint"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-show="activeTab === 'Albums'" class="albums-pane">
|
||||
<ul v-if="albums" class="as-list">
|
||||
<li v-for="album in albums" :key="album.id">
|
||||
<AlbumCard :album="album" layout="compact"/>
|
||||
</li>
|
||||
</ul>
|
||||
<ul v-else class="as-list">
|
||||
<li v-for="i in 12" :key="i">
|
||||
<AlbumCardSkeleton layout="compact"/>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div v-show="activeTab === 'Info'" class="info-pane" v-if="useLastfm && artist">
|
||||
<ArtistInfo :artist="artist" mode="full"/>
|
||||
</div>
|
||||
</section>
|
||||
</ScreenTabs>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { computed, defineAsyncComponent, onMounted, ref, toRef } from 'vue'
|
||||
import { computed, defineAsyncComponent, onMounted, ref, toRef, watch } from 'vue'
|
||||
import { eventBus, logger, pluralize, requireInjection } from '@/utils'
|
||||
import { artistStore, commonStore, songStore } from '@/stores'
|
||||
import { albumStore, artistStore, commonStore, songStore } from '@/stores'
|
||||
import { downloadService } from '@/services'
|
||||
import { useSongList, useThirdPartyServices } from '@/composables'
|
||||
import { DialogBoxKey, RouterKey } from '@/symbols'
|
||||
|
@ -61,17 +95,24 @@ import ScreenHeader from '@/components/ui/ScreenHeader.vue'
|
|||
import ArtistThumbnail from '@/components/ui/AlbumArtistThumbnail.vue'
|
||||
import ScreenHeaderSkeleton from '@/components/ui/skeletons/ScreenHeaderSkeleton.vue'
|
||||
import SongListSkeleton from '@/components/ui/skeletons/SongListSkeleton.vue'
|
||||
import ScreenTabs from '@/components/ui/ArtistAlbumScreenTabs.vue'
|
||||
|
||||
const ArtistInfo = defineAsyncComponent(() => import('@/components/artist/ArtistInfo.vue'))
|
||||
const CloseModalBtn = defineAsyncComponent(() => import('@/components/ui/BtnCloseModal.vue'))
|
||||
const AlbumCard = defineAsyncComponent(() => import('@/components/album/AlbumCard.vue'))
|
||||
const AlbumCardSkeleton = defineAsyncComponent(() => import('@/components/ui/skeletons/ArtistAlbumCardSkeleton.vue'))
|
||||
|
||||
type Tab = 'Songs' | 'Albums' | 'Info'
|
||||
const activeTab = ref<Tab>('Songs')
|
||||
|
||||
const dialog = requireInjection(DialogBoxKey)
|
||||
const router = requireInjection(RouterKey)
|
||||
|
||||
const artistId = ref<number>()
|
||||
const artist = ref<Artist>()
|
||||
const songs = ref<Song[]>([])
|
||||
const showingInfo = ref(false)
|
||||
const loading = ref(false)
|
||||
let albums = ref<Album[] | undefined>()
|
||||
let info = ref<ArtistInfo | undefined | null>()
|
||||
|
||||
const {
|
||||
SongList,
|
||||
|
@ -98,11 +139,15 @@ const albumCount = computed(() => {
|
|||
return albums.size
|
||||
})
|
||||
|
||||
const download = () => downloadService.fromArtist(artist.value!)
|
||||
const showInfo = () => (showingInfo.value = true)
|
||||
watch(activeTab, async tab => {
|
||||
if (tab === 'Albums' && !albums.value) {
|
||||
albums.value = await albumStore.fetchForArtist(artist.value!)
|
||||
}
|
||||
})
|
||||
|
||||
watch(artistId, async id => {
|
||||
if (!id) return
|
||||
|
||||
onMounted(async () => {
|
||||
const id = parseInt(router.$currentRoute.value.params!.id)
|
||||
loading.value = true
|
||||
|
||||
try {
|
||||
|
@ -110,14 +155,18 @@ onMounted(async () => {
|
|||
artistStore.resolve(id),
|
||||
songStore.fetchForArtist(id)
|
||||
])
|
||||
} catch (e) {
|
||||
logger.error(e)
|
||||
} catch (error) {
|
||||
logger.error(error)
|
||||
dialog.value.error('Failed to load artist. Please try again.')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
})
|
||||
|
||||
const download = () => downloadService.fromArtist(artist.value!)
|
||||
|
||||
onMounted(() => (artistId.value = parseInt(router.$currentRoute.value.params!.id)))
|
||||
|
||||
// if the current artist has been deleted, go back to the list
|
||||
eventBus.on('SONGS_UPDATED', () => artistStore.byId(artist.value!.id) || router.go('artists'))
|
||||
</script>
|
||||
|
@ -128,4 +177,16 @@ eventBus.on('SONGS_UPDATED', () => artistStore.byId(artist.value!.id) || router.
|
|||
#artistWrapper {
|
||||
@include artist-album-info-wrapper();
|
||||
}
|
||||
|
||||
.albums-pane {
|
||||
padding: 1.8rem;
|
||||
|
||||
> ul {
|
||||
@include artist-album-wrapper();
|
||||
}
|
||||
}
|
||||
|
||||
.info-pane {
|
||||
padding: 1.8rem;
|
||||
}
|
||||
</style>
|
||||
|
|
|
@ -65,7 +65,7 @@
|
|||
<script lang="ts" setup>
|
||||
import { faFile } from '@fortawesome/free-regular-svg-icons'
|
||||
import { differenceBy } from 'lodash'
|
||||
import { ref, toRef } from 'vue'
|
||||
import { ref, toRef, watch } from 'vue'
|
||||
import { eventBus, pluralize, requireInjection } from '@/utils'
|
||||
import { commonStore, playlistStore, songStore } from '@/stores'
|
||||
import { downloadService } from '@/services'
|
||||
|
@ -78,6 +78,8 @@ import SongListSkeleton from '@/components/ui/skeletons/SongListSkeleton.vue'
|
|||
|
||||
const toaster = requireInjection(MessageToasterKey)
|
||||
const router = requireInjection(RouterKey)
|
||||
|
||||
const playlistId = ref<number>()
|
||||
const playlist = ref<Playlist>()
|
||||
const loading = ref(false)
|
||||
|
||||
|
@ -124,24 +126,16 @@ const fetchSongs = async () => {
|
|||
sort()
|
||||
}
|
||||
|
||||
router.onRouteChanged(async (route) => {
|
||||
if (route.screen !== 'Playlist') return
|
||||
const id = parseInt(route.params!.id)
|
||||
watch(playlistId, async (id) => {
|
||||
if (!id) return
|
||||
|
||||
if (id === playlist.value?.id) return
|
||||
|
||||
const _playlist = playlistStore.byId(id)
|
||||
|
||||
if (!_playlist) {
|
||||
await router.triggerNotFound()
|
||||
return
|
||||
}
|
||||
|
||||
playlist.value = _playlist
|
||||
await fetchSongs()
|
||||
playlist.value = playlistStore.byId(id)
|
||||
playlist.value ? await fetchSongs() : await router.triggerNotFound()
|
||||
})
|
||||
|
||||
router.onRouteChanged(route => route.screen === 'Playlist' && (playlistId.value = parseInt(route.params!.id)))
|
||||
|
||||
eventBus.on({
|
||||
SMART_PLAYLIST_UPDATED: async (updated: Playlist) => updated === playlist.value && await fetchSongs()
|
||||
SMART_PLAYLIST_UPDATED: async (updated: Playlist) => updated.id === playlistId.value && await fetchSongs()
|
||||
})
|
||||
</script>
|
||||
|
|
|
@ -23,6 +23,7 @@
|
|||
</ScreenHeader>
|
||||
|
||||
<SongListSkeleton v-if="loading"/>
|
||||
|
||||
<SongList v-if="songs.length" ref="songList" @press:enter="onPressEnter" @scroll-breakpoint="onScrollBreakpoint"/>
|
||||
|
||||
<ScreenEmptyState v-else>
|
||||
|
|
|
@ -106,7 +106,9 @@ const removeFailedEntries = () => uploadService.removeFailed()
|
|||
#uploadWrapper {
|
||||
.upload-panel {
|
||||
position: relative;
|
||||
height: 100%;
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.upload-files {
|
||||
|
|
|
@ -61,8 +61,11 @@ eventBus.on('PLAY_YOUTUBE_VIDEO', (payload: { id: string, title: string }) => {
|
|||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
#player {
|
||||
::v-deep(#player) {
|
||||
height: 100%;
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
.instruction {
|
||||
font-size: 1.5rem;
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
<template>
|
||||
<div
|
||||
ref="wrapper"
|
||||
class="song-list-wrap main-scroll-wrap"
|
||||
class="song-list-wrap"
|
||||
data-testid="song-list"
|
||||
tabindex="0"
|
||||
@keydown.delete.prevent.stop="handleDelete"
|
||||
|
@ -307,6 +307,7 @@ onMounted(() => render())
|
|||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: scroll;
|
||||
|
||||
@media screen and (max-width: 768px) {
|
||||
padding: 0 12px;
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
// Vitest Snapshot v1
|
||||
|
||||
exports[`renders 1`] = `
|
||||
<div class="song-list-wrap main-scroll-wrap" data-testid="song-list" tabindex="0">
|
||||
<div class="song-list-wrap" data-testid="song-list" tabindex="0">
|
||||
<div class="sortable song-list-header"><span class="track-number" data-testid="header-track-number" role="button" title="Sort by track number"> # <!--v-if--><!--v-if--></span><span class="title" data-testid="header-title" role="button" title="Sort by title"> Title <br data-testid="icon" icon="[object Object]" class="text-highlight"><!--v-if--></span><span class="artist" data-testid="header-artist" role="button" title="Sort by artist"> Artist <!--v-if--><!--v-if--></span><span class="album" data-testid="header-album" role="button" title="Sort by album"> Album <!--v-if--><!--v-if--></span><span class="time" data-testid="header-length" role="button" title="Sort by song duration"><!--v-if--><!--v-if--> <br data-testid="icon" icon="[object Object]" class="duration-header"></span><span class="favorite"></span><span class="play"></span></div><br data-testid="virtual-scroller" item-height="35" items="">
|
||||
</div>
|
||||
`;
|
||||
|
|
60
resources/assets/js/components/ui/ArtistAlbumScreenTabs.vue
Normal file
60
resources/assets/js/components/ui/ArtistAlbumScreenTabs.vue
Normal file
|
@ -0,0 +1,60 @@
|
|||
<template>
|
||||
<div class="tabs">
|
||||
<header>
|
||||
<slot name="header"/>
|
||||
</header>
|
||||
<main>
|
||||
<slot/>
|
||||
</main>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
::v-deep(.tabs) {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
flex: 1;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
::v-deep(header) {
|
||||
display: flex;
|
||||
background: rgba(0, 0, 0, 0.05);
|
||||
overflow: hidden;
|
||||
flex-shrink: 0;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, .04);
|
||||
|
||||
label {
|
||||
font-size: 1rem;
|
||||
position: relative;
|
||||
padding: 1rem 1.8rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1px;
|
||||
border-radius: 0;
|
||||
opacity: .5;
|
||||
cursor: pointer;
|
||||
transition: opacity .2s ease-in-out;
|
||||
|
||||
&:hover {
|
||||
opacity: .8;
|
||||
}
|
||||
|
||||
&.active {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
input {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
::v-deep(main) {
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
}
|
||||
</style>
|
|
@ -54,7 +54,7 @@ const initiatePlayback = async () => {
|
|||
|
||||
<style lang="scss" scoped>
|
||||
button {
|
||||
width: 3rem;
|
||||
width: 3rem !important;
|
||||
border-radius: 50%;
|
||||
border: 2px solid currentColor;
|
||||
|
||||
|
|
|
@ -16,8 +16,7 @@
|
|||
display: flex;
|
||||
place-content: center;
|
||||
place-items: center;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
flex: 1;
|
||||
text-align: center;
|
||||
color: rgba(255, 255, 255, .5);
|
||||
position: relative;
|
||||
|
|
|
@ -33,6 +33,7 @@ header.screen-header {
|
|||
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
flex-shrink: 0;
|
||||
border-bottom: 1px solid var(--color-bg-secondary);
|
||||
position: relative;
|
||||
align-content: stretch;
|
||||
|
|
|
@ -84,5 +84,13 @@ export const albumStore = {
|
|||
this.state.albums = unionBy(this.state.albums, this.syncWithVault(resource.data), 'id')
|
||||
|
||||
return resource.links.next ? ++resource.meta.current_page : null
|
||||
},
|
||||
|
||||
async fetchForArtist (artist: Artist | number) {
|
||||
const id = typeof artist === 'number' ? artist : artist.id
|
||||
|
||||
return this.syncWithVault(
|
||||
await cache.remember<Album[]>(['artist-albums', id], async () => await http.get<Album[]>(`artists/${id}/albums`))
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -5,7 +5,7 @@
|
|||
}
|
||||
|
||||
@mixin artist-album-wrapper() {
|
||||
display: grid;
|
||||
display: grid !important;
|
||||
gap: 16px;
|
||||
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
|
||||
|
||||
|
@ -83,6 +83,15 @@
|
|||
|
||||
a {
|
||||
border-radius: 3px;
|
||||
|
||||
& + a {
|
||||
&::before {
|
||||
content: '•';
|
||||
margin-right: .2rem;
|
||||
color: var(--color-text-secondary);
|
||||
font-weight: unset;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&:hover {
|
||||
|
@ -154,11 +163,14 @@
|
|||
}
|
||||
|
||||
@mixin artist-album-info() {
|
||||
color: var(--color-text-secondary);
|
||||
|
||||
h1 {
|
||||
@include vertical-center();
|
||||
|
||||
font-weight: var(--font-weight-thin);
|
||||
line-height: 2.8rem;
|
||||
margin-bottom: 16px;
|
||||
|
||||
&.name {
|
||||
font-size: 2rem;
|
||||
|
@ -179,7 +191,7 @@
|
|||
}
|
||||
}
|
||||
|
||||
.bio {
|
||||
.bio, .wiki {
|
||||
margin: 16px 0;
|
||||
}
|
||||
|
||||
|
@ -202,10 +214,6 @@
|
|||
overflow: hidden;
|
||||
}
|
||||
|
||||
.wiki {
|
||||
margin: 16px 0;
|
||||
}
|
||||
|
||||
footer {
|
||||
margin-top: 24px;
|
||||
font-size: .9rem;
|
||||
|
@ -229,6 +237,10 @@
|
|||
margin: 0 16px 16px 0;
|
||||
}
|
||||
|
||||
.bio, .wiki {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
h1.name {
|
||||
font-size: 2.4rem;
|
||||
|
||||
|
|
|
@ -30,7 +30,7 @@
|
|||
--color-red: #c34848;
|
||||
|
||||
@media screen and (max-width: 768px) {
|
||||
--header-height: 59px;
|
||||
--header-height: 56px;
|
||||
--footer-height: 96px;
|
||||
--extra-panel-width: 100%;
|
||||
}
|
||||
|
|
|
@ -3,6 +3,7 @@
|
|||
use App\Http\Controllers\API\UserController;
|
||||
use App\Http\Controllers\V6\API\AlbumController;
|
||||
use App\Http\Controllers\V6\API\AlbumSongController;
|
||||
use App\Http\Controllers\V6\API\ArtistAlbumController;
|
||||
use App\Http\Controllers\V6\API\ArtistController;
|
||||
use App\Http\Controllers\V6\API\ArtistSongController;
|
||||
use App\Http\Controllers\V6\API\DataController;
|
||||
|
@ -33,6 +34,7 @@ Route::prefix('api')->middleware('api')->group(static function (): void {
|
|||
Route::apiResource('albums.songs', AlbumSongController::class);
|
||||
Route::apiResource('artists', ArtistController::class);
|
||||
Route::apiResource('artists.songs', ArtistSongController::class);
|
||||
Route::apiResource('artists.albums', ArtistAlbumController::class);
|
||||
|
||||
Route::get('albums/{album}/information', FetchAlbumInformationController::class);
|
||||
Route::get('artists/{artist}/information', FetchArtistInformationController::class);
|
||||
|
|
20
tests/Feature/V6/ArtistAlbumTest.php
Normal file
20
tests/Feature/V6/ArtistAlbumTest.php
Normal file
|
@ -0,0 +1,20 @@
|
|||
<?php
|
||||
|
||||
namespace Tests\Feature\V6;
|
||||
|
||||
use App\Models\Album;
|
||||
use App\Models\Artist;
|
||||
|
||||
class ArtistAlbumTest extends TestCase
|
||||
{
|
||||
public function testIndex(): void
|
||||
{
|
||||
/** @var Artist $artist */
|
||||
$artist = Artist::factory()->create();
|
||||
|
||||
Album::factory(5)->for($artist)->create();
|
||||
|
||||
$this->getAs('api/artists/' . $artist->id . '/albums')
|
||||
->assertJsonStructure(['*' => AlbumTest::JSON_STRUCTURE]);
|
||||
}
|
||||
}
|
Loading…
Reference in a new issue