Merge pull request #1499 from koel/dev/1476-playlist-folder

This commit is contained in:
Phan An 2022-09-10 22:18:37 +07:00 committed by GitHub
commit 4be4742c78
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
129 changed files with 2451 additions and 789 deletions

View file

@ -42,7 +42,7 @@ class PlaylistController extends Controller
public function update(PlaylistUpdateRequest $request, Playlist $playlist)
{
$this->authorize('owner', $playlist);
$this->authorize('own', $playlist);
$playlist->update($request->only('name', 'rules'));
@ -51,7 +51,7 @@ class PlaylistController extends Controller
public function destroy(Playlist $playlist)
{
$this->authorize('owner', $playlist);
$this->authorize('own', $playlist);
$playlist->delete();

View file

@ -16,13 +16,13 @@ class PlaylistSongController extends Controller
public function __construct(
private SmartPlaylistService $smartPlaylistService,
private PlaylistService $playlistService,
private Authenticatable $user
private ?Authenticatable $user
) {
}
public function index(Playlist $playlist)
{
$this->authorize('owner', $playlist);
$this->authorize('own', $playlist);
return response()->json(
$playlist->is_smart
@ -34,7 +34,7 @@ class PlaylistSongController extends Controller
/** @deprecated */
public function update(PlaylistSongUpdateRequest $request, Playlist $playlist)
{
$this->authorize('owner', $playlist);
$this->authorize('own', $playlist);
abort_if($playlist->is_smart, 403, 'A smart playlist cannot be populated manually.');

View file

@ -14,7 +14,7 @@ class PlaylistController extends Controller
public function show(Playlist $playlist)
{
$this->authorize('owner', $playlist);
$this->authorize('own', $playlist);
return response()->download($this->downloadService->from($playlist));
}

View file

@ -3,9 +3,10 @@
namespace App\Http\Controllers\V6\API;
use App\Http\Controllers\Controller;
use App\Http\Resources\PlaylistFolderResource;
use App\Http\Resources\PlaylistResource;
use App\Http\Resources\UserResource;
use App\Models\User;
use App\Repositories\PlaylistRepository;
use App\Repositories\SettingRepository;
use App\Repositories\SongRepository;
use App\Services\ApplicationInformationService;
@ -20,7 +21,6 @@ class DataController extends Controller
public function __construct(
private ITunesService $iTunesService,
private SettingRepository $settingRepository,
private PlaylistRepository $playlistRepository,
private SongRepository $songRepository,
private ApplicationInformationService $applicationInformationService,
private ?Authenticatable $user
@ -31,7 +31,8 @@ class DataController extends Controller
{
return response()->json([
'settings' => $this->user->is_admin ? $this->settingRepository->getAllAsKeyValueArray() : [],
'playlists' => $this->playlistRepository->getAllByCurrentUser(),
'playlists' => PlaylistResource::collection($this->user->playlists),
'playlist_folders' => PlaylistFolderResource::collection($this->user->playlist_folders),
'current_user' => UserResource::make($this->user, true),
'use_last_fm' => LastfmService::used(),
'use_you_tube' => YouTubeService::enabled(),

View file

@ -0,0 +1,60 @@
<?php
namespace App\Http\Controllers\V6\API;
use App\Http\Controllers\Controller;
use App\Http\Requests\API\PlaylistStoreRequest;
use App\Http\Requests\API\PlaylistUpdateRequest;
use App\Http\Resources\PlaylistResource;
use App\Models\Playlist;
use App\Models\User;
use App\Services\PlaylistService;
use Illuminate\Contracts\Auth\Authenticatable;
use Illuminate\Support\Arr;
class PlaylistController extends Controller
{
/** @param User $user */
public function __construct(private PlaylistService $playlistService, private ?Authenticatable $user)
{
}
public function index()
{
return PlaylistResource::collection($this->user->playlists);
}
public function store(PlaylistStoreRequest $request)
{
$playlist = $this->playlistService->createPlaylist(
$request->name,
$this->user,
Arr::wrap($request->songs),
$request->rules
);
return PlaylistResource::make($playlist);
}
public function update(PlaylistUpdateRequest $request, Playlist $playlist)
{
$this->authorize('own', $playlist);
return PlaylistResource::make(
$this->playlistService->updatePlaylist(
$playlist,
$request->name,
Arr::wrap($request->rules)
)
);
}
public function destroy(Playlist $playlist)
{
$this->authorize('own', $playlist);
$playlist->delete();
return response()->noContent();
}
}

View file

@ -0,0 +1,41 @@
<?php
namespace App\Http\Controllers\V6\API;
use App\Http\Controllers\Controller;
use App\Http\Controllers\V6\Requests\PlaylistFolderStoreRequest;
use App\Http\Controllers\V6\Requests\PlaylistFolderUpdateRequest;
use App\Http\Resources\PlaylistFolderResource;
use App\Models\PlaylistFolder;
use App\Models\User;
use App\Services\PlaylistFolderService;
use Illuminate\Contracts\Auth\Authenticatable;
class PlaylistFolderController extends Controller
{
/** @param User $user */
public function __construct(private PlaylistFolderService $service, private ?Authenticatable $user)
{
}
public function store(PlaylistFolderStoreRequest $request)
{
return PlaylistFolderResource::make($this->service->createFolder($this->user, $request->name));
}
public function update(PlaylistFolder $playlistFolder, PlaylistFolderUpdateRequest $request)
{
$this->authorize('own', $playlistFolder);
return PlaylistFolderResource::make($this->service->renameFolder($playlistFolder, $request->name));
}
public function destroy(PlaylistFolder $playlistFolder)
{
$this->authorize('own', $playlistFolder);
$playlistFolder->delete();
return response()->noContent();
}
}

View file

@ -0,0 +1,35 @@
<?php
namespace App\Http\Controllers\V6\API;
use App\Http\Controllers\Controller;
use App\Http\Controllers\V6\Requests\PlaylistFolderPlaylistDestroyRequest;
use App\Http\Controllers\V6\Requests\PlaylistFolderPlaylistStoreRequest;
use App\Models\PlaylistFolder;
use App\Services\PlaylistFolderService;
use Illuminate\Support\Arr;
class PlaylistFolderPlaylistController extends Controller
{
public function __construct(private PlaylistFolderService $service)
{
}
public function store(PlaylistFolder $playlistFolder, PlaylistFolderPlaylistStoreRequest $request)
{
$this->authorize('own', $playlistFolder);
$this->service->addPlaylistsToFolder($playlistFolder, Arr::wrap($request->playlists));
return response()->noContent();
}
public function destroy(PlaylistFolder $playlistFolder, PlaylistFolderPlaylistDestroyRequest $request)
{
$this->authorize('own', $playlistFolder);
$this->service->movePlaylistsToRootLevel(Arr::wrap($request->playlists));
return response()->noContent();
}
}

View file

@ -27,7 +27,7 @@ class PlaylistSongController extends Controller
public function index(Playlist $playlist)
{
$this->authorize('owner', $playlist);
$this->authorize('own', $playlist);
return SongResource::collection(
$playlist->is_smart
@ -36,9 +36,9 @@ class PlaylistSongController extends Controller
);
}
public function add(Playlist $playlist, AddSongsToPlaylistRequest $request)
public function store(Playlist $playlist, AddSongsToPlaylistRequest $request)
{
$this->authorize('owner', $playlist);
$this->authorize('own', $playlist);
abort_if($playlist->is_smart, Response::HTTP_FORBIDDEN);
@ -47,9 +47,9 @@ class PlaylistSongController extends Controller
return response()->noContent();
}
public function remove(Playlist $playlist, RemoveSongsFromPlaylistRequest $request)
public function destroy(Playlist $playlist, RemoveSongsFromPlaylistRequest $request)
{
$this->authorize('owner', $playlist);
$this->authorize('own', $playlist);
abort_if($playlist->is_smart, Response::HTTP_FORBIDDEN);

View file

@ -3,6 +3,8 @@
namespace App\Http\Controllers\V6\Requests;
use App\Http\Requests\API\Request;
use App\Models\Song;
use Illuminate\Validation\Rule;
/**
* @property-read array<string> $songs
@ -14,7 +16,7 @@ class AddSongsToPlaylistRequest extends Request
{
return [
'songs' => 'required|array',
'songs.*' => 'exists:songs,id',
'songs.*' => [Rule::exists(Song::class, 'id')],
];
}
}

View file

@ -0,0 +1,23 @@
<?php
namespace App\Http\Controllers\V6\Requests;
use App\Http\Requests\API\Request;
use App\Models\Playlist;
use App\Rules\AllPlaylistsBelongToUser;
use Illuminate\Validation\Rule;
/**
* @property-read array<int>|int $playlists
*/
class PlaylistFolderPlaylistDestroyRequest extends Request
{
/** @return array<mixed> */
public function rules(): array
{
return [
'playlists' => ['required', 'array', new AllPlaylistsBelongToUser($this->user())],
'playlists.*' => [Rule::exists(Playlist::class, 'id')],
];
}
}

View file

@ -0,0 +1,23 @@
<?php
namespace App\Http\Controllers\V6\Requests;
use App\Http\Requests\API\Request;
use App\Models\Playlist;
use App\Rules\AllPlaylistsBelongToUser;
use Illuminate\Validation\Rule;
/**
* @property-read array<int>|int $playlists
*/
class PlaylistFolderPlaylistStoreRequest extends Request
{
/** @return array<mixed> */
public function rules(): array
{
return [
'playlists' => ['required', 'array', new AllPlaylistsBelongToUser($this->user())],
'playlists.*' => [Rule::exists(Playlist::class, 'id')],
];
}
}

View file

@ -0,0 +1,19 @@
<?php
namespace App\Http\Controllers\V6\Requests;
use App\Http\Requests\API\Request;
/**
* @property-read string $name
*/
class PlaylistFolderStoreRequest extends Request
{
/** @return array<mixed> */
public function rules(): array
{
return [
'name' => 'required|string|max:191',
];
}
}

View file

@ -0,0 +1,19 @@
<?php
namespace App\Http\Controllers\V6\Requests;
use App\Http\Requests\API\Request;
/**
* @property-read string $name
*/
class PlaylistFolderUpdateRequest extends Request
{
/** @return array<mixed> */
public function rules(): array
{
return [
'name' => 'required|string|max:191',
];
}
}

View file

@ -4,6 +4,10 @@ namespace App\Http\Requests\API;
use App\Rules\ValidSmartPlaylistRulePayload;
/**
* @property-read $name
* @property-read array $rules
*/
class PlaylistUpdateRequest extends Request
{
/** @return array<mixed> */

View file

@ -0,0 +1,26 @@
<?php
namespace App\Http\Resources;
use App\Models\PlaylistFolder;
use Illuminate\Http\Resources\Json\JsonResource;
class PlaylistFolderResource extends JsonResource
{
public function __construct(private PlaylistFolder $folder)
{
parent::__construct($folder);
}
/** @return array<mixed> */
public function toArray($request): array
{
return [
'type' => 'playlist_folders',
'id' => $this->folder->id,
'name' => $this->folder->name,
'user_id' => $this->folder->user_id,
'created_at' => $this->folder->created_at,
];
}
}

View file

@ -0,0 +1,29 @@
<?php
namespace App\Http\Resources;
use App\Models\Playlist;
use Illuminate\Http\Resources\Json\JsonResource;
class PlaylistResource extends JsonResource
{
public function __construct(private Playlist $playlist)
{
parent::__construct($playlist);
}
/** @return array<mixed> */
public function toArray($request): array
{
return [
'type' => 'playlists',
'id' => $this->playlist->id,
'name' => $this->playlist->name,
'folder_id' => $this->playlist->folder_id,
'user_id' => $this->playlist->user_id,
'is_smart' => $this->playlist->is_smart,
'rules' => $this->playlist->rules,
'created_at' => $this->playlist->created_at,
];
}
}

View file

@ -4,6 +4,7 @@ namespace App\Models;
use App\Casts\SmartPlaylistRulesCast;
use App\Values\SmartPlaylistRuleGroup;
use Carbon\Carbon;
use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
@ -13,14 +14,17 @@ use Illuminate\Support\Collection;
use Laravel\Scout\Searchable;
/**
* @property int $user_id
* @property Collection|array $songs
* @property int $id
* @property string $name
* @property bool $is_smart
* @property int $user_id
* @property User $user
* @property ?string $folder_id
* @property ?PlaylistFolder $folder
* @property Collection|array<array-key, Song> $songs
* @property Collection|array<array-key, SmartPlaylistRuleGroup> $rule_groups
* @property Collection|array<array-key, SmartPlaylistRuleGroup> $rules
* @property bool $is_smart
* @property string $name
* @property user $user
* @property Carbon $created_at
*/
class Playlist extends Model
{
@ -46,6 +50,11 @@ class Playlist extends Model
return $this->belongsTo(User::class);
}
public function folder(): BelongsTo
{
return $this->belongsTo(PlaylistFolder::class);
}
protected function isSmart(): Attribute
{
return Attribute::get(fn (): bool => $this->rule_groups->isNotEmpty());

View file

@ -0,0 +1,43 @@
<?php
namespace App\Models;
use Carbon\Carbon;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Support\Str;
/**
* @property string $id
* @property string $name
* @property User $user
* @property Collection|array<array-key, Playlist> $playlists
* @property int $user_id
* @property Carbon $created_at
*/
class PlaylistFolder extends Model
{
use HasFactory;
public $incrementing = false;
protected $keyType = 'string';
protected $guarded = ['id'];
protected static function booted(): void
{
static::creating(static fn (self $folder) => $folder->id = Str::uuid()->toString());
}
public function playlists(): HasMany
{
return $this->hasMany(Playlist::class, 'folder_id');
}
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
}

View file

@ -5,6 +5,7 @@ namespace App\Models;
use App\Casts\UserPreferencesCast;
use App\Values\UserPreferences;
use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Foundation\Auth\User as Authenticatable;
@ -20,6 +21,8 @@ use Laravel\Sanctum\HasApiTokens;
* @property string $email
* @property string $password
* @property-read string $avatar
* @property Collection|array<array-key, Playlist> $playlists
* @property Collection|array<array-key, PlaylistFolder> $playlist_folders
*/
class User extends Authenticatable
{
@ -41,6 +44,11 @@ class User extends Authenticatable
return $this->hasMany(Playlist::class);
}
public function playlist_folders(): HasMany // @phpcs:ignore
{
return $this->hasMany(PlaylistFolder::class);
}
public function interactions(): HasMany
{
return $this->hasMany(Interaction::class);

View file

@ -0,0 +1,14 @@
<?php
namespace App\Policies;
use App\Models\PlaylistFolder;
use App\Models\User;
class PlaylistFolderPolicy
{
public function own(User $user, PlaylistFolder $folder): bool
{
return $folder->user->is($user);
}
}

View file

@ -7,7 +7,7 @@ use App\Models\User;
class PlaylistPolicy
{
public function owner(User $user, Playlist $playlist): bool
public function own(User $user, Playlist $playlist): bool
{
return $playlist->user->is($user);
}

View file

@ -3,7 +3,9 @@
namespace App\Providers;
use App\Models\Playlist;
use App\Models\PlaylistFolder;
use App\Models\User;
use App\Policies\PlaylistFolderPolicy;
use App\Policies\PlaylistPolicy;
use App\Policies\UserPolicy;
use App\Services\TokenManager;
@ -17,11 +19,9 @@ class AuthServiceProvider extends ServiceProvider
protected $policies = [
Playlist::class => PlaylistPolicy::class,
User::class => UserPolicy::class,
PlaylistFolder::class => PlaylistFolderPolicy::class,
];
/**
* Register any application authentication / authorization services.
*/
public function boot(): void
{
$this->registerPolicies();

View file

@ -0,0 +1,10 @@
<?php
namespace App\Repositories;
use App\Repositories\Traits\ByCurrentUser;
class PlaylistFolderRepository extends Repository
{
use ByCurrentUser;
}

View file

@ -0,0 +1,25 @@
<?php
namespace App\Rules;
use App\Models\User;
use Illuminate\Contracts\Validation\Rule;
use Illuminate\Support\Arr;
class AllPlaylistsBelongToUser implements Rule
{
public function __construct(private User $user)
{
}
/** @param array<int> $value */
public function passes($attribute, $value): bool
{
return array_diff(Arr::wrap($value), $this->user->playlists->pluck('id')->toArray()) === [];
}
public function message(): string
{
return 'Not all playlists belong to the user';
}
}

View file

@ -0,0 +1,32 @@
<?php
namespace App\Services;
use App\Models\Playlist;
use App\Models\PlaylistFolder;
use App\Models\User;
class PlaylistFolderService
{
public function createFolder(User $user, string $name): PlaylistFolder
{
return $user->playlist_folders()->create(['name' => $name]);
}
public function renameFolder(PlaylistFolder $folder, string $name): PlaylistFolder
{
$folder->update(['name' => $name]);
return $folder;
}
public function addPlaylistsToFolder(PlaylistFolder $folder, array $playlistIds): void
{
Playlist::query()->whereIn('id', $playlistIds)->update(['folder_id' => $folder->id]);
}
public function movePlaylistsToRootLevel(array $playlistIds): void
{
Playlist::query()->whereIn('id', $playlistIds)->update(['folder_id' => null]);
}
}

View file

@ -22,6 +22,16 @@ class PlaylistService
return $playlist;
}
public function updatePlaylist(Playlist $playlist, string $name, array $rules): Playlist
{
$playlist->update([
'name' => $name,
'rules' => $rules,
]);
return $playlist;
}
public function addSongsToPlaylist(Playlist $playlist, array $songIds): void
{
$playlist->songs()->syncWithoutDetaching($songIds);
@ -32,7 +42,7 @@ class PlaylistService
$playlist->songs()->detach($songIds);
}
/** @deprecated */
/** @deprecated since v6.0.0, use add/removeSongs methods instead */
public function populatePlaylist(Playlist $playlist, array $songIds): void
{
$playlist->songs()->sync($songIds);

View file

@ -0,0 +1,21 @@
<?php
namespace Database\Factories;
use App\Models\PlaylistFolder;
use App\Models\User;
use Illuminate\Database\Eloquent\Factories\Factory;
class PlaylistFolderFactory extends Factory
{
protected $model = PlaylistFolder::class;
/** @return array<mixed> */
public function definition(): array
{
return [
'user_id' => User::factory(),
'name' => $this->faker->name,
];
}
}

View file

@ -0,0 +1,27 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('playlist_folders', static function (Blueprint $table): void {
$table->string('id', 36)->primary();
$table->string('name');
$table->unsignedInteger('user_id');
$table->timestamps();
$table->foreign('user_id')->references('id')->on('users')->cascadeOnUpdate()->cascadeOnDelete();
});
Schema::table('playlists', static function (Blueprint $table): void {
$table->string('folder_id', 36)->nullable();
$table->foreign('folder_id')
->references('id')->on('playlist_folders')
->cascadeOnUpdate()
->nullOnDelete();
});
}
};

View file

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

View file

@ -2,7 +2,7 @@ import isMobile from 'ismobilejs'
import { isObject, mergeWith } from 'lodash'
import { cleanup, render, RenderOptions } from '@testing-library/vue'
import { afterEach, beforeEach, vi } from 'vitest'
import { clickaway, droppable, focus } from '@/directives'
import { clickaway, focus } from '@/directives'
import { defineComponent, nextTick } from 'vue'
import { commonStore, userStore } from '@/stores'
import factory from '@/__tests__/factory'
@ -81,8 +81,7 @@ export default abstract class UnitTestCase {
global: {
directives: {
'koel-clickaway': clickaway,
'koel-focus': focus,
'koel-droppable': droppable
'koel-focus': focus
},
components: {
icon: this.stub('icon')
@ -95,11 +94,15 @@ export default abstract class UnitTestCase {
options.global = options.global || {}
options.global.provide = options.global.provide || {}
// @ts-ignore
if (!options.global.provide?.hasOwnProperty(DialogBoxKey)) {
// @ts-ignore
options.global.provide[DialogBoxKey] = DialogBoxStub
}
// @ts-ignore
if (!options.global.provide?.hasOwnProperty(MessageToasterKey)) {
// @ts-ignore
options.global.provide[MessageToasterKey] = MessageToasterStub
}

View file

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

View file

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

View file

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

View file

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

View file

@ -1,6 +1,6 @@
import 'plyr/dist/plyr.js'
import { createApp } from 'vue'
import { clickaway, droppable, focus } from '@/directives'
import { clickaway, focus } from '@/directives'
import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome'
import App from './App.vue'
@ -8,7 +8,6 @@ createApp(App)
.component('icon', FontAwesomeIcon)
.directive('koel-focus', focus)
.directive('koel-clickaway', clickaway)
.directive('koel-droppable', droppable)
/**
* For Ancelot, the ancient cross of war
* for the holy town of Gods

View file

@ -8,7 +8,7 @@
draggable="true"
tabindex="0"
@dblclick="shuffle"
@dragstart="dragStart"
@dragstart="onDragStart"
@contextmenu.prevent="requestContextMenu"
>
<AlbumThumbnail :entity="album"/>
@ -56,12 +56,15 @@
<script lang="ts" setup>
import { faDownload, faRandom } from '@fortawesome/free-solid-svg-icons'
import { computed, toRef, toRefs } from 'vue'
import { eventBus, pluralize, secondsToHis, startDragging } from '@/utils'
import { eventBus, pluralize, secondsToHis } from '@/utils'
import { albumStore, artistStore, commonStore, songStore } from '@/stores'
import { downloadService, playbackService } from '@/services'
import { useDraggable } from '@/composables'
import AlbumThumbnail from '@/components/ui/AlbumArtistThumbnail.vue'
const { startDragging } = useDraggable('album')
const props = withDefaults(defineProps<{ album: Album, layout?: ArtistAlbumCardLayout }>(), { layout: 'full' })
const { album, layout } = toRefs(props)
@ -76,10 +79,10 @@ const shuffle = async () => {
}
const download = () => downloadService.fromAlbum(album.value)
const dragStart = (event: DragEvent) => startDragging(event, album.value, 'Album')
const onDragStart = (event: DragEvent) => startDragging(event, album.value)
const requestContextMenu = (event: MouseEvent) => eventBus.emit('ALBUM_CONTEXT_MENU_REQUESTED', event, album.value)
</script>
<style lang="scss">
<style lang="scss" scoped>
@include artist-album-card();
</style>

View file

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

View file

@ -45,6 +45,6 @@ const download = () => trigger(() => downloadService.fromAlbum(album.value))
eventBus.on('ALBUM_CONTEXT_MENU_REQUESTED', async (e: MouseEvent, _album: Album) => {
album.value = _album
open(e.pageY, e.pageX, { album })
await open(e.pageY, e.pageX, { album })
})
</script>

View file

@ -63,7 +63,7 @@ const showFull = computed(() => !showSummary.value)
const play = async () => playbackService.queueAndPlay(await songStore.fetchForAlbum(album.value))
</script>
<style lang="scss">
<style lang="scss" scoped>
.album-info {
@include artist-album-info();

View file

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

View file

@ -8,7 +8,7 @@
draggable="true"
tabindex="0"
@dblclick="shuffle"
@dragstart="dragStart"
@dragstart="onDragStart"
@contextmenu.prevent="requestContextMenu"
>
<ArtistThumbnail :entity="artist"/>
@ -56,11 +56,15 @@
<script lang="ts" setup>
import { faDownload, faRandom } from '@fortawesome/free-solid-svg-icons'
import { computed, toRef, toRefs } from 'vue'
import { eventBus, pluralize, startDragging } from '@/utils'
import { eventBus, pluralize } from '@/utils'
import { artistStore, commonStore, songStore } from '@/stores'
import { downloadService, playbackService } from '@/services'
import { useDraggable } from '@/composables'
import ArtistThumbnail from '@/components/ui/AlbumArtistThumbnail.vue'
const { startDragging } = useDraggable('artist')
const props = withDefaults(defineProps<{ artist: Artist, layout?: ArtistAlbumCardLayout }>(), { layout: 'full' })
const { artist, layout } = toRefs(props)
@ -73,10 +77,10 @@ const shuffle = async () => {
}
const download = () => downloadService.fromArtist(artist.value)
const dragStart = (event: DragEvent) => startDragging(event, artist.value, 'Artist')
const onDragStart = (event: DragEvent) => startDragging(event, artist.value)
const requestContextMenu = (event: MouseEvent) => eventBus.emit('ARTIST_CONTEXT_MENU_REQUESTED', event, artist.value)
</script>
<style lang="scss">
<style lang="scss" scoped>
@include artist-album-card();
</style>

View file

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

View file

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

View file

@ -59,7 +59,7 @@ const showFull = computed(() => !showSummary.value)
const play = async () => playbackService.queueAndPlay(await songStore.fetchForArtist(artist.value))
</script>
<style lang="scss">
<style lang="scss" scoped>
.artist-info {
@include artist-album-info();

View file

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

View file

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

View file

@ -1,10 +1,14 @@
<template>
<div class="modal-wrapper" :class="{ overlay: showingModalName }">
<CreatePlaylistForm v-if="showingModalName === 'create-playlist-form'" @close="close"/>
<EditPlaylistForm v-else-if="showingModalName === 'edit-playlist-form'" @close="close"/>
<CreateSmartPlaylistForm v-if="showingModalName === 'create-smart-playlist-form'" @close="close"/>
<EditSmartPlaylistForm v-if="showingModalName === 'edit-smart-playlist-form'" @close="close"/>
<AddUserForm v-if="showingModalName === 'add-user-form'" @close="close"/>
<EditUserForm v-if="showingModalName === 'edit-user-form'" @close="close"/>
<EditSongForm v-if="showingModalName === 'edit-song-form'" @close="close"/>
<CreatePlaylistFolderForm v-if="showingModalName === 'create-playlist-folder-form'" @close="close"/>
<EditPlaylistFolderForm v-if="showingModalName === 'edit-playlist-folder-form'" @close="close"/>
<AboutKoel v-if="showingModalName === 'about-koel'" @close="close"/>
</div>
</template>
@ -12,21 +16,29 @@
<script lang="ts" setup>
import { defineAsyncComponent, ref } from 'vue'
import { arrayify, eventBus, provideReadonly } from '@/utils'
import { EditSongFormInitialTabKey, PlaylistKey, SongsKey, UserKey } from '@/symbols'
import { EditSongFormInitialTabKey, PlaylistFolderKey, PlaylistKey, SongsKey, UserKey } from '@/symbols'
declare type ModalName =
| 'create-playlist-form'
| 'edit-playlist-form'
| 'create-smart-playlist-form'
| 'edit-smart-playlist-form'
| 'add-user-form'
| 'edit-user-form'
| 'edit-song-form'
| 'create-playlist-folder-form'
| 'edit-playlist-folder-form'
| 'about-koel'
const CreateSmartPlaylistForm = defineAsyncComponent(() => import('@/components/playlist/smart-playlist/SmartPlaylistCreateForm.vue'))
const EditSmartPlaylistForm = defineAsyncComponent(() => import('@/components/playlist/smart-playlist/SmartPlaylistEditForm.vue'))
const AddUserForm = defineAsyncComponent(() => import('@/components/user/UserAddForm.vue'))
const EditUserForm = defineAsyncComponent(() => import('@/components/user/UserEditForm.vue'))
const EditSongForm = defineAsyncComponent(() => import('@/components/song/SongEditForm.vue'))
const CreatePlaylistForm = defineAsyncComponent(() => import('@/components/playlist/CreatePlaylistForm.vue'))
const EditPlaylistForm = defineAsyncComponent(() => import('@/components/playlist/EditPlaylistForm.vue'))
const CreateSmartPlaylistForm = defineAsyncComponent(() => import('@/components/playlist/smart-playlist/CreateSmartPlaylistForm.vue'))
const EditSmartPlaylistForm = defineAsyncComponent(() => import('@/components/playlist/smart-playlist/EditSmartPlaylistForm.vue'))
const AddUserForm = defineAsyncComponent(() => import('@/components/user/AddUserForm.vue'))
const EditUserForm = defineAsyncComponent(() => import('@/components/user/EditUserForm.vue'))
const EditSongForm = defineAsyncComponent(() => import('@/components/song/EditSongForm.vue'))
const CreatePlaylistFolderForm = defineAsyncComponent(() => import('@/components/playlist/CreatePlaylistFolderForm.vue'))
const EditPlaylistFolderForm = defineAsyncComponent(() => import('@/components/playlist/EditPlaylistFolderForm.vue'))
const AboutKoel = defineAsyncComponent(() => import('@/components/meta/AboutKoelModal.vue'))
const showingModalName = ref<ModalName | null>(null)
@ -36,21 +48,29 @@ const close = () => (showingModalName.value = null)
const playlistToEdit = ref<Playlist>()
const userToEdit = ref<User>()
const songsToEdit = ref<Song[]>()
const playlistFolderToEdit = ref<PlaylistFolder>()
const editSongFormInitialTab = ref<EditSongFormTabName>('details')
provideReadonly(PlaylistKey, playlistToEdit, false)
provideReadonly(UserKey, userToEdit)
provideReadonly(PlaylistFolderKey, playlistFolderToEdit, true, (name: string) => {
playlistFolderToEdit.value!.name = name
})
provideReadonly(SongsKey, songsToEdit, false)
provideReadonly(EditSongFormInitialTabKey, editSongFormInitialTab)
eventBus.on({
'MODAL_SHOW_ABOUT_KOEL': () => (showingModalName.value = 'about-koel'),
'MODAL_SHOW_ADD_USER_FORM': () => (showingModalName.value = 'add-user-form'),
'MODAL_SHOW_CREATE_PLAYLIST_FORM': () => (showingModalName.value = 'create-playlist-form'),
'MODAL_SHOW_CREATE_SMART_PLAYLIST_FORM': () => (showingModalName.value = 'create-smart-playlist-form'),
'MODAL_SHOW_CREATE_PLAYLIST_FOLDER_FORM': () => (showingModalName.value = 'create-playlist-folder-form'),
'MODAL_SHOW_EDIT_SMART_PLAYLIST_FORM': (playlist: Playlist) => {
'MODAL_SHOW_EDIT_PLAYLIST_FORM': (playlist: Playlist) => {
playlistToEdit.value = playlist
showingModalName.value = 'edit-smart-playlist-form'
showingModalName.value = playlist.is_smart ? 'edit-smart-playlist-form' : 'edit-playlist-form'
},
'MODAL_SHOW_EDIT_USER_FORM': (user: User) => {
@ -62,6 +82,11 @@ eventBus.on({
songsToEdit.value = arrayify(songs)
editSongFormInitialTab.value = initialTab
showingModalName.value = 'edit-song-form'
},
'MODAL_SHOW_EDIT_PLAYLIST_FOLDER_FORM': (folder: PlaylistFolder) => {
playlistFolderToEdit.value = folder
showingModalName.value = 'edit-playlist-folder-form'
}
})
</script>
@ -83,10 +108,6 @@ eventBus.on({
padding: 1.2rem;
}
> * + * {
margin-top: 1.25rem;
}
> footer {
margin-top: 0;
}

View file

@ -23,7 +23,7 @@ const props = defineProps<{ song?: Song }>()
const { song } = toRefs(props)
</script>
<style lang="scss">/* no scoping here because we're overriding some plyr classes */
<style lang="scss" scoped>
.middle-pane {
flex: 1;
display: flex;
@ -37,7 +37,7 @@ const { song } = toRefs(props)
}
}
#progressPane {
::v-deep(#progressPane) {
flex: 1;
position: relative;
display: flex;

View file

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

View file

@ -10,8 +10,8 @@
Home
</a>
</li>
<li>
<a v-koel-droppable="handleDrop" :class="['queue', currentView === 'Queue' ? 'active' : '']" href="#!/queue">
<li ref="queueMenuItemEl" @dragleave="onQueueDragLeave" @dragover="onQueueDragOver" @drop="onQueueDrop">
<a :class="['queue', currentView === 'Queue' ? 'active' : '']" href="#!/queue">
<icon :icon="faListOl" fixed-width/>
Current Queue
</a>
@ -86,19 +86,37 @@ import {
} from '@fortawesome/free-solid-svg-icons'
import { faYoutube } from '@fortawesome/free-brands-svg-icons'
import { ref } from 'vue'
import { eventBus, resolveSongsFromDragEvent } from '@/utils'
import { eventBus } from '@/utils'
import { queueStore } from '@/stores'
import { useAuthorization, useThirdPartyServices } from '@/composables'
import { useAuthorization, useDroppable, useThirdPartyServices } from '@/composables'
import PlaylistList from '@/components/playlist/PlaylistSidebarList.vue'
const showing = ref(!isMobile.phone)
const currentView = ref<MainViewName>('Home')
const queueMenuItemEl = ref<HTMLLIElement>()
const { acceptsDrop, resolveDroppedSongs } = useDroppable(['songs', 'album', 'artist', 'playlist'])
const { useYouTube } = useThirdPartyServices()
const { isAdmin } = useAuthorization()
const handleDrop = async (event: DragEvent) => {
const songs = await resolveSongsFromDragEvent(event)
const onQueueDragOver = (event: DragEvent) => {
if (!acceptsDrop(event)) return false
event.preventDefault()
event.dataTransfer!.dropEffect = 'move'
queueMenuItemEl.value?.classList.add('droppable')
}
const onQueueDragLeave = () => queueMenuItemEl.value?.classList.remove('droppable')
const onQueueDrop = async (event: DragEvent) => {
queueMenuItemEl.value?.classList.remove('droppable')
if (!acceptsDrop(event)) return false
event.preventDefault()
const songs = await resolveDroppedSongs(event) || []
songs.length && queueStore.queue(songs)
return false
@ -118,8 +136,8 @@ eventBus.on({
})
</script>
<style lang="scss">
#sidebar {
<style lang="scss" scoped>
nav {
flex: 0 0 256px;
background-color: var(--color-bg-secondary);
padding: 2.05rem 0;
@ -137,13 +155,10 @@ eventBus.on({
-webkit-overflow-scrolling: touch;
}
a.droppable {
transform: scale(1.2);
transition: .3s;
transform-origin: center left;
color: var(--color-text-primary);
background-color: rgba(0, 0, 0, .3);
.droppable {
box-shadow: inset 0 0 0 1px var(--color-accent);
border-radius: 4px;
cursor: copy;
}
.queue > span {
@ -153,38 +168,43 @@ eventBus.on({
flex: 1;
}
section {
h1 {
text-transform: uppercase;
letter-spacing: 1px;
padding: 0 16px;
margin-bottom: 12px;
::v-deep(h1) {
text-transform: uppercase;
letter-spacing: 1px;
padding: 0 16px;
margin-bottom: 12px;
}
::v-deep(a) {
display: flex;
align-items: center;
gap: .7rem;
height: 36px;
line-height: 36px;
padding: 0 16px 0 12px;
border-left: 4px solid transparent;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
&.active, &:hover {
border-left-color: var(--color-highlight);
color: var(--color-text-primary);
background: rgba(255, 255, 255, .05);
box-shadow: 0 1px 0 rgba(0, 0, 0, .1);
}
a {
display: flex;
align-items: center;
gap: .7rem;
height: 36px;
line-height: 36px;
padding: 0 16px 0 12px;
border-left: 4px solid transparent;
&.active, &:hover {
border-left-color: var(--color-highlight);
color: var(--color-text-primary);
background: rgba(255, 255, 255, .05);
box-shadow: 0 1px 0 rgba(0, 0, 0, .1);
}
&:active {
opacity: .5;
}
&:hover {
border-left-color: var(--color-highlight);
}
&:active {
opacity: .5;
}
&:hover {
border-left-color: var(--color-highlight);
}
}
::v-deep(li li a) { // submenu items
padding-left: 24px;
}
@media only screen and (max-width: 667px) {

View file

@ -19,6 +19,7 @@ new class extends UnitTestCase {
})
it('shows demo notation', () => {
// @ts-ignore
import.meta.env.VITE_KOEL_ENV = 'demo'
this.render(AboutKoelModel).findByTestId('demo-credits')
})

View file

@ -13,7 +13,7 @@
<p v-if="shouldNotifyNewVersion" data-testid="new-version-about">
<a :href="latestVersionReleaseUrl" target="_blank">
A new Koel version is available ({{ latestVersion }}).
A new version of Koel is available ({{ latestVersion }})!
</a>
</p>

View file

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

View file

@ -1,20 +1,34 @@
<template>
<ContextMenuBase ref="base" extra-class="playlist-menu">
<li data-testid="playlist-context-menu-create-simple" @click="createPlaylist">New Playlist</li>
<li data-testid="playlist-context-menu-create-smart" @click="createSmartPlaylist">New Smart Playlist</li>
<ContextMenuBase ref="base">
<li data-testid="playlist-context-menu-create-simple" @click="onItemClicked('new-playlist')">New Playlist</li>
<li data-testid="playlist-context-menu-create-smart" @click="onItemClicked('new-smart-playlist')">
New Smart Playlist
</li>
<li data-testid="playlist-context-menu-create-folder" @click="onItemClicked('new-folder')">New Folder</li>
</ContextMenuBase>
</template>
<script lang="ts" setup>
import { eventBus } from '@/utils'
import { useContextMenu } from '@/composables'
import { eventBus } from '@/utils'
import { EventName } from '@/config'
import { onMounted } from 'vue'
const { base, ContextMenuBase, open, trigger } = useContextMenu()
const emit = defineEmits(['createPlaylist'])
const emit = defineEmits(['itemClicked'])
const createPlaylist = () => trigger(() => emit('createPlaylist'))
const createSmartPlaylist = () => trigger(() => eventBus.emit('MODAL_SHOW_CREATE_SMART_PLAYLIST_FORM'))
const actionToEventMap: Record<string, EventName> = {
'new-playlist': 'MODAL_SHOW_CREATE_PLAYLIST_FORM',
'new-smart-playlist': 'MODAL_SHOW_CREATE_SMART_PLAYLIST_FORM',
'new-folder': 'MODAL_SHOW_CREATE_PLAYLIST_FOLDER_FORM'
}
defineExpose({ open })
const onItemClicked = (key: keyof typeof actionToEventMap) => trigger(() => eventBus.emit(actionToEventMap[key]))
onMounted(() => {
eventBus.on('CREATE_NEW_PLAYLIST_CONTEXT_MENU_REQUESTED', async (e: MouseEvent) => {
await open(e.pageY, e.pageX)
})
})
</script>

View file

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

View file

@ -0,0 +1,72 @@
<template>
<div tabindex="0" @keydown.esc="maybeClose">
<SoundBars v-if="loading"/>
<form v-else @submit.prevent="submit">
<header>
<h1>New Playlist Folder</h1>
</header>
<main>
<div class="form-row">
<input
v-model="name"
v-koel-focus
name="name"
placeholder="Folder name"
required
type="text"
>
</div>
</main>
<footer>
<Btn type="submit">Save</Btn>
<Btn white @click.prevent="maybeClose">Cancel</Btn>
</footer>
</form>
</div>
</template>
<script lang="ts" setup>
import { ref } from 'vue'
import { playlistFolderStore } from '@/stores'
import { logger, requireInjection } from '@/utils'
import { DialogBoxKey, MessageToasterKey } from '@/symbols'
import SoundBars from '@/components/ui/SoundBars.vue'
import Btn from '@/components/ui/Btn.vue'
const toaster = requireInjection(MessageToasterKey)
const dialog = requireInjection(DialogBoxKey)
const loading = ref(false)
const name = ref('')
const emit = defineEmits(['close'])
const close = () => emit('close')
const submit = async () => {
loading.value = true
try {
const folder = await playlistFolderStore.store(name.value)
close()
toaster.value.success(`Playlist folder "${folder.name}" created.`)
} catch (error) {
dialog.value.error('Something went wrong. Please try again.')
logger.error(error)
} finally {
loading.value = false
}
}
const maybeClose = async () => {
if (name.value.trim() === '') {
close()
return
}
await dialog.value.confirm('Discard all changes?') && close()
}
</script>

View file

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

View file

@ -0,0 +1,72 @@
<template>
<div @keydown.esc="maybeClose">
<SoundBars v-if="loading"/>
<form v-else @submit.prevent="submit">
<header>
<h1>New Playlist</h1>
</header>
<main>
<div class="form-row">
<input
v-model="name"
v-koel-focus
name="name"
placeholder="Playlist name"
required
type="text"
>
</div>
</main>
<footer>
<Btn type="submit">Save</Btn>
<Btn white @click.prevent="maybeClose">Cancel</Btn>
</footer>
</form>
</div>
</template>
<script lang="ts" setup>
import { ref } from 'vue'
import { playlistStore } from '@/stores'
import { logger, requireInjection } from '@/utils'
import { DialogBoxKey, MessageToasterKey } from '@/symbols'
import SoundBars from '@/components/ui/SoundBars.vue'
import Btn from '@/components/ui/Btn.vue'
const toaster = requireInjection(MessageToasterKey)
const dialog = requireInjection(DialogBoxKey)
const loading = ref(false)
const name = ref('')
const emit = defineEmits(['close'])
const close = () => emit('close')
const submit = async () => {
loading.value = true
try {
const folder = await playlistStore.store(name.value)
close()
toaster.value.success(`Playlist "${folder.name}" created.`)
} catch (error) {
dialog.value.error('Something went wrong. Please try again.')
logger.error(error)
} finally {
loading.value = false
}
}
const maybeClose = async () => {
if (name.value.trim() === '') {
close()
return
}
await dialog.value.confirm('Discard all changes?') && close()
}
</script>

View file

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

View file

@ -0,0 +1,75 @@
<template>
<div @keydown.esc="maybeClose">
<SoundBars v-if="loading"/>
<form v-else data-testid="edit-playlist-folder-form" @submit.prevent="submit">
<header>
<h1>Rename Playlist Folder</h1>
</header>
<main>
<div class="form-row">
<input
v-model="name"
v-koel-focus
name="name"
placeholder="Folder name"
required
title="Folder name"
type="text"
>
</div>
</main>
<footer>
<Btn type="submit">Save</Btn>
<Btn white @click.prevent="maybeClose">Cancel</Btn>
</footer>
</form>
</div>
</template>
<script lang="ts" setup>
import { ref } from 'vue'
import { logger, requireInjection } from '@/utils'
import { playlistFolderStore } from '@/stores'
import { DialogBoxKey, MessageToasterKey, PlaylistFolderKey } from '@/symbols'
import Btn from '@/components/ui/Btn.vue'
import SoundBars from '@/components/ui/SoundBars.vue'
const toaster = requireInjection(MessageToasterKey)
const dialog = requireInjection(DialogBoxKey)
const [folder, updateFolderName] = requireInjection(PlaylistFolderKey)
const name = ref(folder.value.name)
const loading = ref(false)
const submit = async () => {
loading.value = true
try {
await playlistFolderStore.rename(folder.value, name.value)
updateFolderName(name.value)
toaster.value.success('Playlist folder renamed.')
close()
} catch (error) {
dialog.value.error('Something went wrong. Please try again.')
logger.error(error)
} finally {
loading.value = false
}
}
const emit = defineEmits(['close'])
const close = () => emit('close')
const maybeClose = async () => {
if (name.value.trim() === folder.value.name) {
close()
return
}
await dialog.value.confirm('Discard all changes?') && close()
}
</script>

View file

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

View file

@ -0,0 +1,75 @@
<template>
<div @keydown.esc="maybeClose">
<SoundBars v-if="loading"/>
<form v-else data-testid="edit-playlist-form" @submit.prevent="submit">
<header>
<h1>Rename Playlist</h1>
</header>
<main>
<div class="form-row">
<input
v-model="name"
v-koel-focus
name="name"
placeholder="Playlist name"
required
title="Playlist name"
type="text"
>
</div>
</main>
<footer>
<Btn type="submit">Save</Btn>
<Btn white @click.prevent="maybeClose">Cancel</Btn>
</footer>
</form>
</div>
</template>
<script lang="ts" setup>
import { ref } from 'vue'
import { logger, requireInjection } from '@/utils'
import { playlistStore } from '@/stores'
import { DialogBoxKey, MessageToasterKey, PlaylistKey } from '@/symbols'
import Btn from '@/components/ui/Btn.vue'
import SoundBars from '@/components/ui/SoundBars.vue'
const toaster = requireInjection(MessageToasterKey)
const dialog = requireInjection(DialogBoxKey)
const [playlist, updatePlaylistName] = requireInjection(PlaylistKey)
const name = ref(playlist.value.name)
const loading = ref(false)
const submit = async () => {
loading.value = true
try {
await playlistStore.update(playlist.value, { name: name.value })
updatePlaylistName(name.value)
toaster.value.success('Playlist renamed.')
close()
} catch (error) {
dialog.value.error('Something went wrong. Please try again.')
logger.error(error)
} finally {
loading.value = false
}
}
const emit = defineEmits(['close'])
const close = () => emit('close')
const maybeClose = async () => {
if (name.value.trim() === playlist.value.name) {
close()
return
}
await dialog.value.confirm('Discard all changes?') && close()
}
</script>

View file

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

View file

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

View file

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

View file

@ -0,0 +1,49 @@
<template>
<ContextMenuBase ref="base">
<template v-if="folder">
<template v-if="playable">
<li @click="play">Play All</li>
<li @click="shuffle">Shuffle All</li>
<li class="separator"/>
</template>
<li @click="rename">Rename</li>
<li @click="destroy">Delete</li>
</template>
</ContextMenuBase>
</template>
<script lang="ts" setup>
import { computed, ref } from 'vue'
import { useContextMenu } from '@/composables'
import { eventBus, requireInjection } from '@/utils'
import { playlistStore, songStore } from '@/stores'
import { playbackService } from '@/services'
import { DialogBoxKey } from '@/symbols'
import router from '@/router'
const { context, base, ContextMenuBase, open, trigger } = useContextMenu()
const dialog = requireInjection(DialogBoxKey)
const folder = ref<PlaylistFolder>()
const playlistsInFolder = computed(() => folder.value ? playlistStore.byFolder(folder.value) : [])
const playable = computed(() => playlistsInFolder.value.length > 0)
const play = () => trigger(async () => {
await playbackService.queueAndPlay(await songStore.fetchForPlaylistFolder(folder.value!))
router.go('queue')
})
const shuffle = () => trigger(async () => {
await playbackService.queueAndPlay(await songStore.fetchForPlaylistFolder(folder.value!), true)
router.go('queue')
})
const rename = () => trigger(() => eventBus.emit('MODAL_SHOW_EDIT_PLAYLIST_FOLDER_FORM', folder.value))
const destroy = () => trigger(() => eventBus.emit('PLAYLIST_FOLDER_DELETE', folder.value))
eventBus.on('PLAYLIST_FOLDER_CONTEXT_MENU_REQUESTED', async (e: MouseEvent, _folder: PlaylistFolder) => {
folder.value = _folder
await open(e.pageY, e.pageX, { folder })
})
</script>

View file

@ -0,0 +1,122 @@
<template>
<li
ref="el"
class="playlist-folder"
@dragleave="onDragLeave"
@dragover="onDragOver"
@drop="onDrop"
tabindex="0"
>
<a @click.prevent="toggle" @contextmenu.prevent="onContextMenu">
<icon :icon="opened ? faFolderOpen : faFolder" fixed-width/>
{{ folder.name }}
</a>
<ul v-if="playlistsInFolder.length" v-show="opened">
<PlaylistSidebarItem v-for="playlist in playlistsInFolder" :key="playlist.id" :list="playlist" class="sub-item"/>
</ul>
<div
v-if="opened"
ref="hatch"
class="hatch"
@dragleave.prevent="onDragLeaveHatch"
@dragover="onDragOverHatch"
@drop.prevent="onDropOnHatch"
/>
</li>
</template>
<script lang="ts" setup>
import { faFolder, faFolderOpen } from '@fortawesome/free-solid-svg-icons'
import { computed, defineAsyncComponent, ref, toRefs } from 'vue'
import { playlistFolderStore, playlistStore } from '@/stores'
import { eventBus } from '@/utils'
import { useDroppable } from '@/composables'
const PlaylistSidebarItem = defineAsyncComponent(() => import('@/components/playlist/PlaylistSidebarItem.vue'))
const props = defineProps<{ folder: PlaylistFolder }>()
const { folder } = toRefs(props)
const el = ref<HTMLLIElement>()
const hatch = ref<HTMLLIElement>()
const opened = ref(false)
const playlistsInFolder = computed(() => playlistStore.byFolder(folder.value))
const { acceptsDrop, resolveDroppedValue } = useDroppable(['playlist'])
const toggle = () => (opened.value = !opened.value)
const onDragOver = (event: DragEvent) => {
if (!acceptsDrop(event)) return false
event.preventDefault()
event.dataTransfer!.dropEffect = 'move'
el.value?.classList.add('droppable')
opened.value = true
}
const onDragLeave = () => el.value?.classList.remove('droppable')
const onDrop = async (event: DragEvent) => {
if (!acceptsDrop(event)) return false
event.preventDefault()
el.value?.classList.remove('droppable')
const playlist = await resolveDroppedValue<Playlist>(event)
if (!playlist || playlist.folder_id === folder.value.id) return
await playlistFolderStore.addPlaylistToFolder(folder.value, playlist)
}
const onDragLeaveHatch = () => hatch.value?.classList.remove('droppable')
const onDragOverHatch = (event: DragEvent) => {
if (!acceptsDrop(event)) return false
event.preventDefault()
event.dataTransfer!.dropEffect = 'move'
hatch.value?.classList.add('droppable')
}
const onDropOnHatch = async (event: DragEvent) => {
hatch.value?.classList.remove('droppable')
el.value?.classList.remove('droppable')
const playlist = (await resolveDroppedValue<Playlist>(event))!
// if the playlist isn't in the folder, don't do anything. The folder will handle the drop.
if (playlist.folder_id !== folder.value.id) return
// otherwise, the user is trying to remove the playlist from the folder.
event.stopPropagation()
await playlistFolderStore.removePlaylistFromFolder(folder.value, playlist)
}
const onContextMenu = event => eventBus.emit('PLAYLIST_FOLDER_CONTEXT_MENU_REQUESTED', event, folder.value)
</script>
<style lang="scss" scoped>
li.playlist-folder {
position: relative;
&.droppable {
box-shadow: inset 0 0 0 1px var(--color-accent);
border-radius: 4px;
cursor: copy;
}
.hatch {
position: absolute;
bottom: 0;
width: 100%;
height: .5rem;
&.droppable {
border-bottom: 3px solid var(--color-highlight);
}
}
}
</style>

View file

@ -1,56 +0,0 @@
import factory from '@/__tests__/factory'
import { expect, it } from 'vitest'
import { fireEvent } from '@testing-library/vue'
import { playlistStore } from '@/stores'
import UnitTestCase from '@/__tests__/UnitTestCase'
import PlaylistNameEditor from './PlaylistNameEditor.vue'
let playlist: Playlist
new class extends UnitTestCase {
private renderComponent () {
playlist = factory<Playlist>('playlist', {
id: 99,
name: 'Foo'
})
return this.render(PlaylistNameEditor, {
props: {
playlist
}
}).getByRole('textbox')
}
protected test () {
it('updates a playlist name on blur', async () => {
const updateMock = this.mock(playlistStore, 'update')
const input = this.renderComponent()
await fireEvent.update(input, 'Bar')
await fireEvent.blur(input)
expect(updateMock).toHaveBeenCalledWith(playlist, { name: 'Bar' })
})
it('updates a playlist name on enter', async () => {
const updateMock = this.mock(playlistStore, 'update')
const input = this.renderComponent()
await fireEvent.update(input, 'Bar')
await fireEvent.keyUp(input, { key: 'Enter' })
expect(updateMock).toHaveBeenCalledWith(playlist, { name: 'Bar' })
})
it('cancels updating on esc', async () => {
const updateMock = this.mock(playlistStore, 'update')
const input = this.renderComponent()
await fireEvent.update(input, 'Bar')
await fireEvent.keyUp(input, { key: 'Esc' })
expect(input.value).toBe('Foo')
expect(updateMock).not.toHaveBeenCalled()
})
}
}

View file

@ -1,62 +0,0 @@
<template>
<input
v-model="name"
v-koel-focus
data-testid="inline-playlist-name-input"
name="name"
required
type="text"
@blur="update"
@keyup.esc="cancel"
@keyup.enter="update"
>
</template>
<script lang="ts" setup>
import { reactive, ref, toRefs } from 'vue'
import { playlistStore } from '@/stores'
import { logger, requireInjection } from '@/utils'
import { DialogBoxKey, MessageToasterKey } from '@/symbols'
const toaster = requireInjection(MessageToasterKey)
const dialog = requireInjection(DialogBoxKey)
const props = defineProps<{ playlist: Playlist }>()
const { playlist } = toRefs(props)
let updating = false
const mutablePlaylist = reactive<Playlist>(Object.assign({}, playlist.value))
const name = ref(mutablePlaylist.name)
const emit = defineEmits(['updated', 'cancelled'])
const update = async () => {
if (!name.value || name.value === playlist.value.name) {
cancel()
return
}
// prevent duplicate updating from Enter and Blur
if (updating) {
return
}
updating = true
try {
await playlistStore.update(mutablePlaylist, { name: name.value })
toaster.value.success(`Playlist "${name.value}" updated.`)
emit('updated', name.value)
} catch (error) {
dialog.value.error('Something went wrong. Please try again.')
logger.error(error)
} finally {
updating = false
}
}
const cancel = () => {
name.value = playlist.value.name
emit('cancelled')
}
</script>

View file

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

View file

@ -1,141 +1,123 @@
<template>
<li
:class="['playlist', type, editing ? 'editing' : '', playlist.is_smart ? 'smart' : '']"
ref="el"
class="playlist"
data-testid="playlist-sidebar-item"
@dblclick.prevent="makeEditable"
draggable="true"
@contextmenu="onContextMenu"
@dragleave="onDragLeave"
@dragover="onDragOver"
@dragstart="onDragStart"
@drop="onDrop"
>
<a
v-if="contentEditable"
v-koel-droppable="handleDrop"
:class="{ active }"
:href="url"
@contextmenu.prevent="openContextMenu"
>
<icon v-if="type === 'favorites'" :icon="faHeart" class="text-maroon" fixed-width/>
<icon v-else :icon="faMusic" :mask="faFile" transform="shrink-7 down-2" fixed-width/>
{{ playlist.name }}
<a :class="{ active }" :href="url">
<icon v-if="isRecentlyPlayedList(list)" :icon="faClockRotateLeft" class="text-green" fixed-width/>
<icon v-else-if="isFavoriteList(list)" :icon="faHeart" class="text-maroon" fixed-width/>
<icon
v-else-if="list.is_smart"
:icon="faBoltLightning"
:mask="faFile"
fixed-width
transform="shrink-7 down-2"
/>
<icon v-else :icon="faMusic" :mask="faFile" fixed-width transform="shrink-7 down-2"/>
{{ list.name }}
</a>
<a v-else :class="{ active }" :href="url" @contextmenu.prevent="openContextMenu">
<icon v-if="type === 'recently-played'" :icon="faClockRotateLeft" class="text-green" fixed-width/>
<icon v-else :icon="faBoltLightning" :mask="faFile" transform="shrink-7 down-2" fixed-width/>
{{ playlist.name }}
</a>
<NameEditor
v-if="nameEditable && editing"
:playlist="playlist"
@cancelled="cancelEditing"
@updated="onPlaylistNameUpdated"
/>
<ContextMenu v-if="hasContextMenu" ref="contextMenu" :playlist="playlist" @edit="makeEditable"/>
</li>
</template>
<script lang="ts" setup>
import { faBoltLightning, faClockRotateLeft, faFile, faHeart, faMusic } from '@fortawesome/free-solid-svg-icons'
import { computed, defineAsyncComponent, nextTick, ref, toRefs } from 'vue'
import { eventBus, pluralize, requireInjection, resolveSongsFromDragEvent } from '@/utils'
import { computed, ref, toRefs } from 'vue'
import { eventBus, pluralize, requireInjection } from '@/utils'
import { favoriteStore, playlistStore } from '@/stores'
import router from '@/router'
import { MessageToasterKey } from '@/symbols'
import { useDraggable, useDroppable } from '@/composables'
const ContextMenu = defineAsyncComponent(() => import('@/components/playlist/PlaylistContextMenu.vue'))
const NameEditor = defineAsyncComponent(() => import('@/components/playlist/PlaylistNameEditor.vue'))
const { startDragging } = useDraggable('playlist')
const { acceptsDrop, resolveDroppedSongs } = useDroppable(['songs', 'album', 'artist'])
const toaster = requireInjection(MessageToasterKey)
const contextMenu = ref<InstanceType<typeof ContextMenu>>()
const el = ref<HTMLLIElement>()
const props = withDefaults(defineProps<{ playlist: Playlist, type?: PlaylistType }>(), { type: 'playlist' })
const { playlist, type } = toRefs(props)
const props = defineProps<{ list: PlaylistLike }>()
const { list } = toRefs(props)
const isPlaylist = (list: PlaylistLike): list is Playlist => 'id' in list
const isFavoriteList = (list: PlaylistLike): list is FavoriteList => list.name === 'Favorites'
const isRecentlyPlayedList = (list: PlaylistLike): list is RecentlyPlayedList => list.name === 'Recently Played'
const editing = ref(false)
const active = ref(false)
const url = computed(() => {
switch (type.value) {
case 'playlist':
return `#!/playlist/${playlist.value.id}`
if (isPlaylist(list.value)) return `#!/playlist/${list.value.id}`
if (isFavoriteList(list.value)) return '#!/favorites'
if (isRecentlyPlayedList(list.value)) return '#!/recently-played'
case 'favorites':
return '#!/favorites'
case 'recently-played':
return '#!/recently-played'
default:
throw new Error('Invalid playlist type')
}
throw new Error('Invalid playlist-like type.')
})
const nameEditable = computed(() => type.value === 'playlist')
const hasContextMenu = computed(() => type.value === 'playlist')
const contentEditable = computed(() => {
if (playlist.value.is_smart) {
return false
}
if (isRecentlyPlayedList(list.value)) return false
if (isFavoriteList(list.value)) return true
return type.value === 'playlist' || type.value === 'favorites'
return !list.value.is_smart
})
const makeEditable = () => {
if (!nameEditable.value) {
return
const onContextMenu = (event: MouseEvent) => {
if (isPlaylist(list.value)) {
event.preventDefault()
eventBus.emit('PLAYLIST_CONTEXT_MENU_REQUESTED', event, list.value)
}
editing.value = true
}
const handleDrop = async (event: DragEvent) => {
if (!contentEditable.value) {
return false
}
const onDragStart = (event: DragEvent) => isPlaylist(list.value) && startDragging(event, list.value)
const songs = await resolveSongsFromDragEvent(event)
const onDragOver = (event: DragEvent) => {
if (!contentEditable.value) return false
if (!acceptsDrop(event)) return false
if (!songs.length) {
return false
}
event.preventDefault()
event.dataTransfer!.dropEffect = 'copy'
el.value?.classList.add('droppable')
if (type.value === 'favorites') {
return false
}
const onDragLeave = () => el.value?.classList.remove('droppable')
const onDrop = async (event: DragEvent) => {
el.value?.classList.remove('droppable')
if (!contentEditable.value) return false
if (!acceptsDrop(event)) return false
const songs = await resolveDroppedSongs(event)
if (!songs?.length) return false
if (isFavoriteList(list.value)) {
await favoriteStore.like(songs)
} else if (type.value === 'playlist') {
await playlistStore.addSongs(playlist.value, songs)
toaster.value.success(`Added ${pluralize(songs.length, 'song')} into "${playlist.value.name}."`)
} else if (isPlaylist(list.value)) {
await playlistStore.addSongs(list.value, songs)
toaster.value.success(`Added ${pluralize(songs, 'song')} into "${list.value.name}."`)
}
return false
}
const openContextMenu = async (event: MouseEvent) => {
if (hasContextMenu.value) {
await nextTick()
router.go(`/playlist/${playlist.value.id}`)
contextMenu.value?.open(event.pageY, event.pageX, { playlist })
}
}
const cancelEditing = () => (editing.value = false)
const onPlaylistNameUpdated = (name: string) => {
playlist.value.name = name
editing.value = false
}
eventBus.on('LOAD_MAIN_CONTENT', (view: MainViewName, _playlist: Playlist): void => {
eventBus.on('LOAD_MAIN_CONTENT', (view: MainViewName, _list: PlaylistLike): void => {
switch (view) {
case 'Favorites':
active.value = type.value === 'favorites'
active.value = isFavoriteList(list.value)
break
case 'RecentlyPlayed':
active.value = type.value === 'recently-played'
active.value = isRecentlyPlayedList(list.value)
break
case 'Playlist':
active.value = playlist.value === _playlist
active.value = list.value === _list
break
default:
@ -150,12 +132,13 @@ eventBus.on('LOAD_MAIN_CONTENT', (view: MainViewName, _playlist: Playlist): void
user-select: none;
overflow: hidden;
a {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
display: block;
&.droppable {
box-shadow: inset 0 0 0 1px var(--color-accent);
border-radius: 4px;
cursor: copy;
}
::v-deep(a) {
span {
pointer-events: none;
}
@ -165,11 +148,5 @@ eventBus.on('LOAD_MAIN_CONTENT', (view: MainViewName, _playlist: Playlist): void
width: calc(100% - 32px);
margin: 5px 16px;
}
&.editing {
a {
display: none !important;
}
}
}
</style>

View file

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

View file

@ -3,83 +3,43 @@
<h1>
<span>Playlists</span>
<icon
:class="{ creating }"
:icon="faCirclePlus"
class="control create"
data-testid="sidebar-create-playlist-btn"
role="button"
title="Create a new playlist"
@click.stop.prevent="toggleContextMenu"
title="Create a new playlist or folder"
@click.stop.prevent="requestContextMenu"
/>
</h1>
<form v-if="creating" @submit.prevent="createPlaylist" name="create-simple-playlist-form" class="create">
<input
v-model="newName"
v-koel-focus
name="name"
placeholder="↵ to save"
required
type="text"
@keyup.esc.prevent="creating = false"
>
</form>
<ul>
<PlaylistSidebarItem :playlist="{ name: 'Favorites', songs: favorites }" type="favorites"/>
<PlaylistSidebarItem :playlist="{ name: 'Recently Played', songs: [] }" type="recently-played"/>
<PlaylistSidebarItem
v-for="playlist in playlists"
:key="playlist.id"
:playlist="playlist"
type="playlist"
/>
<PlaylistSidebarItem :list="{ name: 'Favorites', songs: favorites }"/>
<PlaylistSidebarItem :list="{ name: 'Recently Played', songs: [] }"/>
<PlaylistFolderSidebarItem v-for="folder in folders" :key="folder.id" :folder="folder"/>
<PlaylistSidebarItem v-for="playlist in orphanPlaylists" :key="playlist.id" :list="playlist"/>
</ul>
<ContextMenu ref="contextMenu" @createPlaylist="creating = true"/>
</section>
</template>
<script lang="ts" setup>
import { faCirclePlus } from '@fortawesome/free-solid-svg-icons'
import { nextTick, ref, toRef } from 'vue'
import { favoriteStore, playlistStore } from '@/stores'
import router from '@/router'
import { requireInjection } from '@/utils'
import { computed, toRef } from 'vue'
import { favoriteStore, playlistFolderStore, playlistStore } from '@/stores'
import { eventBus, requireInjection } from '@/utils'
import { MessageToasterKey } from '@/symbols'
import PlaylistSidebarItem from '@/components/playlist/PlaylistSidebarItem.vue'
import ContextMenu from '@/components/playlist/CreateNewPlaylistContextMenu.vue'
import PlaylistFolderSidebarItem from '@/components/playlist/PlaylistFolderSidebarItem.vue'
const toaster = requireInjection(MessageToasterKey)
const contextMenu = ref<InstanceType<typeof ContextMenu>>()
const folders = toRef(playlistFolderStore.state, 'folders')
const playlists = toRef(playlistStore.state, 'playlists')
const favorites = toRef(favoriteStore.state, 'songs')
const creating = ref(false)
const newName = ref('')
const createPlaylist = async () => {
creating.value = false
const orphanPlaylists = computed(() => playlists.value.filter(playlist => playlist.folder_id === null))
const playlist = await playlistStore.store(newName.value)
newName.value = ''
toaster.value.success(`Playlist "${playlist.name}" created.`)
// Activate the new playlist right away
await nextTick()
router.go(`playlist/${playlist.id}`)
}
const toggleContextMenu = async (event: MouseEvent) => {
await nextTick()
if (creating.value) {
creating.value = false
} else {
contextMenu.value?.open(event.pageY, event.pageX)
}
}
const requestContextMenu = (event: MouseEvent) => eventBus.emit('CREATE_NEW_PLAYLIST_CONTEXT_MENU_REQUESTED', event)
</script>
<style lang="scss">
@ -100,13 +60,5 @@ const toggleContextMenu = async (event: MouseEvent) => {
transform: rotate(135deg);
}
}
form.create {
padding: 8px 16px;
input[type="text"] {
width: 100%;
}
}
}
</style>

View file

@ -9,8 +9,7 @@
<main>
<div class="form-row">
<label>Name</label>
<input v-model="name" v-koel-focus name="name" required type="text">
<input v-model="name" v-koel-focus name="name" placeholder="Playlist name" required type="text">
</div>
<div class="form-row rules">

View file

@ -9,10 +9,13 @@
<main>
<div class="form-row">
<label>
Name
<input v-model="mutablePlaylist.name" v-koel-focus name="name" required type="text">
</label>
<input
v-model="mutablePlaylist.name"
v-koel-focus name="name"
placeholder="Playlist name"
required
type="text"
>
</div>
<div class="form-row rules">

View file

@ -7,12 +7,12 @@
<script lang="ts" setup>
</script>
<style lang="scss">
<style lang="scss" scoped>
.smart-playlist-form {
width: 560px;
}
.rules {
::v-deep(.rules) {
background: rgba(0, 0, 0, .1);
border: 1px solid rgba(255, 255, 255, .1);
padding: .75rem;

View file

@ -9,7 +9,7 @@
</template>
<template v-slot:meta v-if="songs.length">
<span>{{ pluralize(songs.length, 'song') }}</span>
<span>{{ pluralize(songs, 'song') }}</span>
<span>{{ duration }}</span>
<a

View file

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

View file

@ -9,7 +9,7 @@
</template>
<template v-slot:meta v-if="songs.length">
<span>{{ pluralize(songs.length, 'song') }}</span>
<span>{{ pluralize(songs, 'song') }}</span>
<span>{{ duration }}</span>
</template>

View file

@ -9,7 +9,7 @@
</template>
<template v-slot:meta v-if="songs.length">
<span>{{ pluralize(songs.length, 'song') }}</span>
<span>{{ pluralize(songs, 'song') }}</span>
<span>{{ duration }}</span>
</template>

View file

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

View file

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

View file

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

View file

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

View file

@ -9,7 +9,7 @@
</template>
<template v-slot:meta v-if="songs.length">
<span>{{ pluralize(songs.length, 'song') }}</span>
<span>{{ pluralize(songs, 'song') }}</span>
<span>{{ duration }}</span>
</template>

View file

@ -9,7 +9,7 @@
@keydown.esc="close"
>
<section class="existing-playlists">
<p>Add {{ pluralize(songs.length, 'song') }} to</p>
<p>Add {{ pluralize(songs, 'song') }} to</p>
<ul>
<template v-if="config.queue">

View file

@ -7,7 +7,7 @@ import { ref } from 'vue'
import { fireEvent } from '@testing-library/vue'
import { songStore } from '@/stores'
import { MessageToasterStub } from '@/__tests__/stubs'
import SongEditForm from './SongEditForm.vue'
import EditSongForm from './EditSongForm.vue'
let songs: Song[]
@ -15,11 +15,11 @@ new class extends UnitTestCase {
private async renderComponent (_songs: Song | Song[], initialTab: EditSongFormTabName = 'details') {
songs = arrayify(_songs)
const rendered = this.render(SongEditForm, {
const rendered = this.render(EditSongForm, {
global: {
provide: {
[SongsKey]: [ref(songs)],
[EditSongFormInitialTabKey]: [ref(initialTab)]
[<symbol>SongsKey]: [ref(songs)],
[<symbol>EditSongFormInitialTabKey]: [ref(initialTab)]
}
}
})
@ -70,7 +70,7 @@ new class extends UnitTestCase {
const updateMock = this.mock(songStore, 'update')
const alertMock = this.mock(MessageToasterStub.value, 'success')
const { html, getByTestId, getByRole, queryByTestId } = await this.renderComponent(factory<Song[]>('song', 3))
const { html, getByTestId, getByRole, queryByTestId } = await this.renderComponent(factory<Song>('song', 3))
expect(html()).toMatchSnapshot()
expect(queryByTestId('title-input')).toBeNull()
@ -96,12 +96,12 @@ new class extends UnitTestCase {
})
it('displays artist name if all songs have the same artist', async () => {
const { getByTestId } = await this.renderComponent(factory<Song[]>('song', {
const { getByTestId } = await this.renderComponent(factory<Song>('song', 4, {
artist_id: 1000,
artist_name: 'Led Zeppelin',
album_id: 1001,
album_name: 'IV'
}, 4))
}))
expect(getByTestId('displayed-artist-name').textContent).toBe('Led Zeppelin')
expect(getByTestId('displayed-album-name').textContent).toBe('IV')

View file

@ -286,7 +286,7 @@ const submit = async () => {
try {
await songStore.update(mutatedSongs.value, formData)
toaster.value.success(`Updated ${pluralize(mutatedSongs.value.length, 'song')}.`)
toaster.value.success(`Updated ${pluralize(mutatedSongs.value, 'song')}.`)
close()
} finally {
loading.value = false
@ -301,6 +301,7 @@ form {
max-width: 540px;
.tabs {
margin-top: 1.125rem;
padding: 0;
}

View file

@ -4,7 +4,7 @@
data-testid="song-card"
draggable="true"
tabindex="0"
@dragstart="dragStart"
@dragstart="onDragStart"
@contextmenu.prevent="requestContextMenu"
@dblclick.prevent="play"
>
@ -29,17 +29,20 @@
<script lang="ts" setup>
import { faPause, faPlay } from '@fortawesome/free-solid-svg-icons'
import { toRefs } from 'vue'
import { defaultCover, eventBus, pluralize, startDragging } from '@/utils'
import { defaultCover, eventBus, pluralize } from '@/utils'
import { queueStore } from '@/stores'
import { playbackService } from '@/services'
import { useDraggable } from '@/composables'
import LikeButton from '@/components/song/SongLikeButton.vue'
const props = defineProps<{ song: Song }>()
const { song } = toRefs(props)
const { startDragging } = useDraggable('songs')
const requestContextMenu = (event: MouseEvent) => eventBus.emit('SONG_CONTEXT_MENU_REQUESTED', event, song.value)
const dragStart = (event: DragEvent) => startDragging(event, song.value, 'Song')
const onDragStart = (event: DragEvent) => startDragging(event, [song.value])
const play = () => {
queueStore.queueIfNotQueued(song.value)

View file

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

View file

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

View file

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

View file

@ -105,7 +105,8 @@ import isMobile from 'ismobilejs'
import { faAngleDown, faAngleUp } from '@fortawesome/free-solid-svg-icons'
import { faClock } from '@fortawesome/free-regular-svg-icons'
import { computed, nextTick, onMounted, ref, watch } from 'vue'
import { eventBus, requireInjection, startDragging } from '@/utils'
import { eventBus, requireInjection } from '@/utils'
import { useDraggable } from '@/composables'
import {
SelectedSongsKey,
SongListConfigKey,
@ -118,6 +119,8 @@ import {
import VirtualScroller from '@/components/ui/VirtualScroller.vue'
import SongListItem from '@/components/song/SongListItem.vue'
const { startDragging } = useDraggable('songs')
const emit = defineEmits(['press:enter', 'press:delete', 'reorder', 'sort', 'scroll-breakpoint', 'scrolled-to-end'])
const [items] = requireInjection(SongsKey)
@ -260,7 +263,7 @@ const rowDragStart = (row: SongRow, event: DragEvent) => {
row.selected = true
}
startDragging(event, selectedSongs.value, 'Song')
startDragging(event, selectedSongs.value)
}
/**

View file

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

View file

@ -4,7 +4,7 @@
</span>
</template>
<style lang="scss">
<style lang="scss" scoped>
.btn-group {
--radius: 9999px;
@ -12,7 +12,7 @@
position: relative;
flex-wrap: nowrap;
button {
::v-deep(button) {
&:not(:first-child) {
border-radius: 0;
}
@ -30,7 +30,7 @@
}
}
&[uppercased] button {
&[uppercased] ::v-deep(button) {
text-transform: uppercase;
}
}

View file

@ -40,7 +40,7 @@
}
.text {
font-size: 2em;
font-size: 2rem;
font-weight: 200;
line-height: 1.3;

View file

@ -23,7 +23,7 @@
const props = withDefaults(defineProps<{ layout?: ScreenHeaderLayout }>(), { layout: 'expanded' })
</script>
<style lang="scss">
<style lang="scss" scoped>
header.screen-header {
--transition-duration: .3s;
@ -114,7 +114,7 @@ header.screen-header {
}
}
> * + * {
> ::v-deep(* + *) {
margin-left: .2rem;
display: inline-block;

View file

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

View file

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

View file

@ -2,8 +2,8 @@ import { expect, it } from 'vitest'
import UnitTestCase from '@/__tests__/UnitTestCase'
import { fireEvent, waitFor } from '@testing-library/vue'
import { userStore } from '@/stores'
import UserAddForm from './UserAddForm.vue'
import { MessageToasterStub } from '@/__tests__/stubs'
import AddUserForm from './AddUserForm.vue'
new class extends UnitTestCase {
protected test () {
@ -11,7 +11,7 @@ new class extends UnitTestCase {
const storeMock = this.mock(userStore, 'store')
const alertMock = this.mock(MessageToasterStub.value, 'success')
const { getByLabelText, getByRole } = this.render(UserAddForm)
const { getByLabelText, getByRole } = this.render(AddUserForm)
await fireEvent.update(getByLabelText('Name'), 'John Doe')
await fireEvent.update(getByLabelText('Email'), 'john@doe.com')

Some files were not shown because too many files have changed in this diff Show more