mirror of
https://github.com/koel/koel
synced 2024-11-10 06:34:14 +00:00
Merge pull request #1499 from koel/dev/1476-playlist-folder
This commit is contained in:
commit
4be4742c78
129 changed files with 2451 additions and 789 deletions
|
@ -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();
|
||||
|
||||
|
|
|
@ -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.');
|
||||
|
||||
|
|
|
@ -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));
|
||||
}
|
||||
|
|
|
@ -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(),
|
||||
|
|
60
app/Http/Controllers/V6/API/PlaylistController.php
Normal file
60
app/Http/Controllers/V6/API/PlaylistController.php
Normal 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();
|
||||
}
|
||||
}
|
41
app/Http/Controllers/V6/API/PlaylistFolderController.php
Normal file
41
app/Http/Controllers/V6/API/PlaylistFolderController.php
Normal 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();
|
||||
}
|
||||
}
|
|
@ -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();
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
|
||||
|
|
|
@ -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')],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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')],
|
||||
];
|
||||
}
|
||||
}
|
|
@ -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')],
|
||||
];
|
||||
}
|
||||
}
|
|
@ -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',
|
||||
];
|
||||
}
|
||||
}
|
|
@ -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',
|
||||
];
|
||||
}
|
||||
}
|
|
@ -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> */
|
||||
|
|
26
app/Http/Resources/PlaylistFolderResource.php
Normal file
26
app/Http/Resources/PlaylistFolderResource.php
Normal 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,
|
||||
];
|
||||
}
|
||||
}
|
29
app/Http/Resources/PlaylistResource.php
Normal file
29
app/Http/Resources/PlaylistResource.php
Normal 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,
|
||||
];
|
||||
}
|
||||
}
|
|
@ -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());
|
||||
|
|
43
app/Models/PlaylistFolder.php
Normal file
43
app/Models/PlaylistFolder.php
Normal 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);
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
|
|
14
app/Policies/PlaylistFolderPolicy.php
Normal file
14
app/Policies/PlaylistFolderPolicy.php
Normal 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);
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
|
|
|
@ -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();
|
||||
|
|
10
app/Repositories/PlaylistFolderRepository.php
Normal file
10
app/Repositories/PlaylistFolderRepository.php
Normal file
|
@ -0,0 +1,10 @@
|
|||
<?php
|
||||
|
||||
namespace App\Repositories;
|
||||
|
||||
use App\Repositories\Traits\ByCurrentUser;
|
||||
|
||||
class PlaylistFolderRepository extends Repository
|
||||
{
|
||||
use ByCurrentUser;
|
||||
}
|
25
app/Rules/AllPlaylistsBelongToUser.php
Normal file
25
app/Rules/AllPlaylistsBelongToUser.php
Normal 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';
|
||||
}
|
||||
}
|
32
app/Services/PlaylistFolderService.php
Normal file
32
app/Services/PlaylistFolderService.php
Normal 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]);
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
|
|
21
database/factories/PlaylistFolderFactory.php
Normal file
21
database/factories/PlaylistFolderFactory.php
Normal 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,
|
||||
];
|
||||
}
|
||||
}
|
|
@ -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();
|
||||
});
|
||||
}
|
||||
};
|
|
@ -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 {
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
@ -6,6 +6,7 @@ import interactionFactory from '@/__tests__/factory/interactionFactory'
|
|||
import smartPlaylistRuleFactory from '@/__tests__/factory/smartPlaylistRuleFactory'
|
||||
import smartPlaylistRuleGroupFactory from '@/__tests__/factory/smartPlaylistRuleGroupFactory'
|
||||
import playlistFactory, { states as playlistStates } from '@/__tests__/factory/playlistFactory'
|
||||
import playlistFolderFactory from '@/__tests__/factory/playlistFolderFactory'
|
||||
import userFactory, { states as userStates } from '@/__tests__/factory/userFactory'
|
||||
import albumTrackFactory from '@/__tests__/factory/albumTrackFactory'
|
||||
import albumInfoFactory from '@/__tests__/factory/albumInfoFactory'
|
||||
|
@ -24,4 +25,5 @@ export default factory
|
|||
.define('smart-playlist-rule', faker => smartPlaylistRuleFactory(faker))
|
||||
.define('smart-playlist-rule-group', faker => smartPlaylistRuleGroupFactory(faker))
|
||||
.define('playlist', faker => playlistFactory(faker), playlistStates)
|
||||
.define('playlist-folder', faker => playlistFolderFactory(faker))
|
||||
.define('user', faker => userFactory(faker), userStates)
|
||||
|
|
|
@ -4,16 +4,20 @@ import { Faker } from '@faker-js/faker'
|
|||
export default (faker: Faker): Playlist => ({
|
||||
type: 'playlists',
|
||||
id: faker.datatype.number(),
|
||||
folder_id: faker.datatype.uuid(),
|
||||
name: faker.random.word(),
|
||||
is_smart: false,
|
||||
rules: []
|
||||
})
|
||||
|
||||
export const states: Record<string, () => Omit<Partial<Playlist>, 'type'>> = {
|
||||
smart: faker => ({
|
||||
export const states: Record<string, (faker: Faker) => Omit<Partial<Playlist>, 'type'>> = {
|
||||
smart: _ => ({
|
||||
is_smart: true,
|
||||
rules: [
|
||||
factory<SmartPlaylistRule>('smart-playlist-rule')
|
||||
factory<SmartPlaylistRuleGroup>('smart-playlist-rule-group')
|
||||
]
|
||||
}),
|
||||
orphan: _ => ({
|
||||
folder_id: null
|
||||
})
|
||||
}
|
||||
|
|
|
@ -0,0 +1,7 @@
|
|||
import { Faker } from '@faker-js/faker'
|
||||
|
||||
export default (faker: Faker): PlaylistFolder => ({
|
||||
type: 'playlist-folders',
|
||||
id: faker.datatype.uuid(),
|
||||
name: faker.random.word()
|
||||
})
|
|
@ -3,6 +3,6 @@ import { Faker } from '@faker-js/faker'
|
|||
export default (faker: Faker): SmartPlaylistRule => ({
|
||||
id: faker.datatype.number(),
|
||||
model: faker.random.arrayElement<SmartPlaylistModel['name']>(['title', 'artist.name', 'album.name']),
|
||||
operator: faker.random.arrayElement<SmartPlaylistOperator['name']>(['is', 'contains', 'isNot']),
|
||||
operator: faker.random.arrayElement<SmartPlaylistOperator['operator']>(['is', 'contains', 'isNot']),
|
||||
value: [faker.random.word()]
|
||||
})
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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();
|
||||
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -19,7 +19,7 @@ new class extends UnitTestCase {
|
|||
})
|
||||
|
||||
const rendered = this.render(ArtistContextMenu)
|
||||
eventBus.emit('ARTIST_CONTEXT_MENU_REQUESTED', { pageX: 420, pageY: 69 }, artist)
|
||||
eventBus.emit('ARTIST_CONTEXT_MENU_REQUESTED', { pageX: 420, pageY: 42 }, artist)
|
||||
await this.tick(2)
|
||||
|
||||
return rendered
|
||||
|
|
|
@ -44,6 +44,6 @@ const download = () => trigger(() => downloadService.fromArtist(artist.value))
|
|||
|
||||
eventBus.on('ARTIST_CONTEXT_MENU_REQUESTED', async (e: MouseEvent, _artist: Artist) => {
|
||||
artist.value = _artist
|
||||
open(e.pageY, e.pageX, { _artist })
|
||||
await open(e.pageY, e.pageX, { _artist })
|
||||
})
|
||||
</script>
|
||||
|
|
|
@ -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();
|
||||
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
// Vitest Snapshot v1
|
||||
|
||||
exports[`renders 1`] = `
|
||||
<nav class="artist-menu menu context-menu" style="top: 69px; left: 420px;" tabindex="0" data-testid="artist-context-menu">
|
||||
<nav class="artist-menu menu context-menu" style="top: 42px; left: 420px;" tabindex="0" data-testid="artist-context-menu">
|
||||
<ul>
|
||||
<li data-testid="play">Play All</li>
|
||||
<li data-testid="shuffle">Shuffle All</li>
|
||||
|
|
|
@ -1,25 +1,45 @@
|
|||
import { it } from 'vitest'
|
||||
import { waitFor } from '@testing-library/vue'
|
||||
import factory from '@/__tests__/factory'
|
||||
import { eventBus } from '@/utils'
|
||||
import { it } from 'vitest'
|
||||
import { EventName } from '@/config'
|
||||
import UnitTestCase from '@/__tests__/UnitTestCase'
|
||||
import ModalWrapper from './ModalWrapper.vue'
|
||||
|
||||
new class extends UnitTestCase {
|
||||
protected test () {
|
||||
it.each<[string, EventName, User | Song | Playlist | any]>([
|
||||
it.each<[string, EventName, User | Song[] | Playlist | PlaylistFolder | undefined]>([
|
||||
['add-user-form', 'MODAL_SHOW_ADD_USER_FORM', undefined],
|
||||
['edit-user-form', 'MODAL_SHOW_EDIT_USER_FORM', factory('user')],
|
||||
['edit-song-form', 'MODAL_SHOW_EDIT_SONG_FORM', [factory('song')]],
|
||||
['edit-user-form', 'MODAL_SHOW_EDIT_USER_FORM', factory<User>('user')],
|
||||
['edit-song-form', 'MODAL_SHOW_EDIT_SONG_FORM', [factory<Song>('song')]],
|
||||
['create-playlist-form', 'MODAL_SHOW_CREATE_PLAYLIST_FORM', undefined],
|
||||
['create-playlist-folder-form', 'MODAL_SHOW_CREATE_PLAYLIST_FOLDER_FORM', undefined],
|
||||
['edit-playlist-folder-form', 'MODAL_SHOW_EDIT_PLAYLIST_FOLDER_FORM', factory<PlaylistFolder>('playlist-folder')],
|
||||
['create-smart-playlist-form', 'MODAL_SHOW_CREATE_SMART_PLAYLIST_FORM', undefined],
|
||||
['edit-smart-playlist-form', 'MODAL_SHOW_EDIT_SMART_PLAYLIST_FORM', factory('playlist')],
|
||||
['edit-playlist-form', 'MODAL_SHOW_EDIT_PLAYLIST_FORM', factory<Playlist>('playlist')],
|
||||
['edit-smart-playlist-form', 'MODAL_SHOW_EDIT_PLAYLIST_FORM', factory<Playlist>('playlist', { is_smart: true })],
|
||||
['about-koel', 'MODAL_SHOW_ABOUT_KOEL', undefined]
|
||||
])('shows %s modal', async (modalName: string, eventName: EventName, eventParams?: any) => {
|
||||
const { findByTestId } = this.render(ModalWrapper)
|
||||
const { getByTestId } = this.render(ModalWrapper, {
|
||||
global: {
|
||||
stubs: {
|
||||
AddUserForm: this.stub('add-user-form'),
|
||||
EditUserForm: this.stub('edit-user-form'),
|
||||
EditSongForm: this.stub('edit-song-form'),
|
||||
CreatePlaylistForm: this.stub('create-playlist-form'),
|
||||
CreatePlaylistFolderForm: this.stub('create-playlist-folder-form'),
|
||||
EditPlaylistFolderForm: this.stub('edit-playlist-folder-form'),
|
||||
CreateSmartPlaylistForm: this.stub('create-smart-playlist-form'),
|
||||
EditPlaylistForm: this.stub('edit-playlist-form'),
|
||||
EditSmartPlaylistForm: this.stub('edit-smart-playlist-form'),
|
||||
AboutKoel: this.stub('about-koel')
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
eventBus.emit(eventName, eventParams)
|
||||
|
||||
findByTestId(modalName)
|
||||
await waitFor(() => getByTestId(modalName))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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>
|
||||
`;
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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')
|
||||
})
|
||||
|
|
|
@ -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>
|
||||
|
||||
|
|
|
@ -0,0 +1,28 @@
|
|||
import { expect, it } from 'vitest'
|
||||
import { fireEvent, waitFor } from '@testing-library/vue'
|
||||
import UnitTestCase from '@/__tests__/UnitTestCase'
|
||||
import { eventBus } from '@/utils'
|
||||
import { EventName } from '@/config'
|
||||
import CreateNewPlaylistContextMenu from './CreateNewPlaylistContextMenu.vue'
|
||||
|
||||
new class extends UnitTestCase {
|
||||
private async renderComponent () {
|
||||
const rendered = await this.render(CreateNewPlaylistContextMenu)
|
||||
eventBus.emit('CREATE_NEW_PLAYLIST_CONTEXT_MENU_REQUESTED', { pageX: 420, pageY: 42 })
|
||||
await this.tick(2)
|
||||
return rendered
|
||||
}
|
||||
|
||||
protected test () {
|
||||
it.each<[string, EventName]>([
|
||||
['playlist-context-menu-create-simple', 'MODAL_SHOW_CREATE_PLAYLIST_FORM'],
|
||||
['playlist-context-menu-create-smart', 'MODAL_SHOW_CREATE_SMART_PLAYLIST_FORM'],
|
||||
['playlist-context-menu-create-folder', 'MODAL_SHOW_CREATE_PLAYLIST_FOLDER_FORM']
|
||||
])('when clicking on %s, should emit %s', async (id, eventName) => {
|
||||
const { getByTestId } = await this.renderComponent()
|
||||
const emitMock = this.mock(eventBus, 'emit')
|
||||
await fireEvent.click(getByTestId(id))
|
||||
await waitFor(() => expect(emitMock).toHaveBeenCalledWith(eventName))
|
||||
})
|
||||
}
|
||||
}
|
|
@ -1,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>
|
||||
|
|
|
@ -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')
|
||||
})
|
||||
}
|
||||
}
|
|
@ -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>
|
|
@ -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')
|
||||
})
|
||||
}
|
||||
}
|
|
@ -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>
|
|
@ -0,0 +1,33 @@
|
|||
import { ref } from 'vue'
|
||||
import { fireEvent, waitFor } from '@testing-library/vue'
|
||||
import { expect, it, vi } from 'vitest'
|
||||
import factory from '@/__tests__/factory'
|
||||
import UnitTestCase from '@/__tests__/UnitTestCase'
|
||||
import { playlistFolderStore } from '@/stores'
|
||||
import { PlaylistFolderKey } from '@/symbols'
|
||||
import EditPlaylistFolderForm from './EditPlaylistFolderForm.vue'
|
||||
|
||||
new class extends UnitTestCase {
|
||||
protected test () {
|
||||
it('submits', async () => {
|
||||
const folder = factory<PlaylistFolder>('playlist-folder', { name: 'My folder' })
|
||||
const updateFolderNameMock = vi.fn()
|
||||
const renameMock = this.mock(playlistFolderStore, 'rename')
|
||||
const { getByPlaceholderText, getByRole } = this.render(EditPlaylistFolderForm, {
|
||||
global: {
|
||||
provide: {
|
||||
[<symbol>PlaylistFolderKey]: [ref(folder), updateFolderNameMock]
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
await fireEvent.update(getByPlaceholderText('Folder name'), 'Your folder')
|
||||
await fireEvent.click(getByRole('button', { name: 'Save' }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(renameMock).toHaveBeenCalledWith(folder, 'Your folder')
|
||||
expect(updateFolderNameMock).toHaveBeenCalledWith('Your folder')
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
|
@ -0,0 +1,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>
|
|
@ -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')
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
75
resources/assets/js/components/playlist/EditPlaylistForm.vue
Normal file
75
resources/assets/js/components/playlist/EditPlaylistForm.vue
Normal 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>
|
|
@ -0,0 +1,47 @@
|
|||
import { expect, it } from 'vitest'
|
||||
import UnitTestCase from '@/__tests__/UnitTestCase'
|
||||
import { eventBus } from '@/utils'
|
||||
import factory from '@/__tests__/factory'
|
||||
import { fireEvent } from '@testing-library/vue'
|
||||
import PlaylistContextMenu from './PlaylistContextMenu.vue'
|
||||
|
||||
new class extends UnitTestCase {
|
||||
private async renderComponent (playlist: Playlist) {
|
||||
const rendered = await this.render(PlaylistContextMenu)
|
||||
eventBus.emit('PLAYLIST_CONTEXT_MENU_REQUESTED', { pageX: 420, pageY: 42 }, playlist)
|
||||
await this.tick(2)
|
||||
return rendered
|
||||
}
|
||||
|
||||
protected test () {
|
||||
it('renames a standard playlist', async () => {
|
||||
const playlist = factory<Playlist>('playlist')
|
||||
const { getByText } = await this.renderComponent(playlist)
|
||||
const emitMock = this.mock(eventBus, 'emit')
|
||||
|
||||
await fireEvent.click(getByText('Rename'))
|
||||
|
||||
expect(emitMock).toHaveBeenCalledWith('MODAL_SHOW_EDIT_PLAYLIST_FORM', playlist)
|
||||
})
|
||||
|
||||
it('edits a smart playlist', async () => {
|
||||
const playlist = factory.states('smart')<Playlist>('playlist')
|
||||
const { getByText } = await this.renderComponent(playlist)
|
||||
const emitMock = this.mock(eventBus, 'emit')
|
||||
|
||||
await fireEvent.click(getByText('Edit'))
|
||||
|
||||
expect(emitMock).toHaveBeenCalledWith('MODAL_SHOW_EDIT_PLAYLIST_FORM', playlist)
|
||||
})
|
||||
|
||||
it('deletes a playlist', async () => {
|
||||
const playlist = factory<Playlist>('playlist')
|
||||
const { getByText } = await this.renderComponent(playlist)
|
||||
const emitMock = this.mock(eventBus, 'emit')
|
||||
|
||||
await fireEvent.click(getByText('Delete'))
|
||||
|
||||
expect(emitMock).toHaveBeenCalledWith('PLAYLIST_DELETE', playlist)
|
||||
})
|
||||
}
|
||||
}
|
|
@ -1,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>
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
|
@ -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>
|
|
@ -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>
|
|
@ -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()
|
||||
})
|
||||
}
|
||||
}
|
|
@ -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>
|
|
@ -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)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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">
|
|
@ -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">
|
|
@ -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;
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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 () => {
|
||||
|
|
|
@ -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>
|
||||
|
||||
|
|
|
@ -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>
|
||||
|
||||
|
|
|
@ -3,15 +3,15 @@
|
|||
exports[`renders 1`] = `
|
||||
<section id="albumWrapper" data-v-748fe44c="">
|
||||
<!--v-if-->
|
||||
<header class="screen-header expanded" data-v-748fe44c="">
|
||||
<aside class="thumbnail-wrapper"><span class="cover" data-testid="album-artist-thumbnail" data-v-901ba52c="" data-v-748fe44c=""><a class="control control-play" href="" role="button" data-v-901ba52c=""><span class="hidden" data-v-901ba52c="">Play all songs in the album Led Zeppelin IV</span><span class="icon" data-v-901ba52c=""></span></a></span></aside>
|
||||
<main>
|
||||
<div class="heading-wrapper">
|
||||
<h1 class="name">Led Zeppelin IV
|
||||
<header class="screen-header expanded" data-v-661f8f0d="" data-v-748fe44c="">
|
||||
<aside class="thumbnail-wrapper" data-v-661f8f0d=""><span class="cover" data-testid="album-artist-thumbnail" data-v-901ba52c="" data-v-748fe44c=""><a class="control control-play" href="" role="button" data-v-901ba52c=""><span class="hidden" data-v-901ba52c="">Play all songs in the album Led Zeppelin IV</span><span class="icon" data-v-901ba52c=""></span></a></span></aside>
|
||||
<main data-v-661f8f0d="">
|
||||
<div class="heading-wrapper" data-v-661f8f0d="">
|
||||
<h1 class="name" data-v-661f8f0d="">Led Zeppelin IV
|
||||
<!--v-if-->
|
||||
</h1><span class="meta text-secondary"><a href="#!/artist/123" class="artist" data-v-748fe44c="">Led Zeppelin</a><span data-v-748fe44c="">10 songs</span><span data-v-748fe44c="">26:43</span><a class="info" href="" title="View album information" data-v-748fe44c="">Info</a><a class="download" href="" role="button" title="Download all songs in album" data-v-748fe44c=""> Download All </a></span>
|
||||
</h1><span class="meta text-secondary" data-v-661f8f0d=""><a href="#!/artist/123" class="artist" data-v-748fe44c="">Led Zeppelin</a><span data-v-748fe44c="">10 songs</span><span data-v-748fe44c="">26:43</span><a class="info" href="" title="View album information" data-v-748fe44c="">Info</a><a class="download" href="" role="button" title="Download all songs in album" data-v-748fe44c=""> Download All </a></span>
|
||||
</div>
|
||||
<div class="song-list-controls" data-testid="song-list-controls" data-v-cee28c08="" data-v-748fe44c=""><span class="btn-group" uppercased="" data-v-cee28c08=""><button type="button" class="btn-shuffle-all" data-testid="btn-shuffle-all" orange="" title="Shuffle all songs" data-v-27deb898="" data-v-cee28c08=""><br data-testid="icon" icon="[object Object]" data-v-cee28c08=""> All </button><!--v-if--><!--v-if--><!--v-if--><!--v-if--></span>
|
||||
<div class="song-list-controls" data-testid="song-list-controls" data-v-cee28c08="" data-v-748fe44c=""><span class="btn-group" uppercased="" data-v-5d6fa912="" data-v-cee28c08=""><button type="button" class="btn-shuffle-all" data-testid="btn-shuffle-all" orange="" title="Shuffle all songs" data-v-27deb898="" data-v-cee28c08=""><br data-testid="icon" icon="[object Object]" data-v-cee28c08=""> All </button><!--v-if--><!--v-if--><!--v-if--><!--v-if--></span>
|
||||
<div class="add-to" data-testid="add-to-menu" tabindex="0" data-v-0351ff38="" data-v-cee28c08="" style="display: none;">
|
||||
<section class="existing-playlists" data-v-0351ff38="">
|
||||
<p data-v-0351ff38="">Add 0 songs to</p>
|
||||
|
|
|
@ -159,3 +159,35 @@ exports[`renders 5`] = `
|
|||
</header><br data-testid="song-list">
|
||||
</section>
|
||||
`;
|
||||
|
||||
exports[`renders 6`] = `
|
||||
<section id="songsWrapper">
|
||||
<header class="screen-header expanded" data-v-661f8f0d="">
|
||||
<aside class="thumbnail-wrapper" data-v-661f8f0d="">
|
||||
<div class="thumbnail-stack single" style="background-image: url(undefined/resources/assets/img/covers/default.svg);" data-v-2978c570=""><span data-testid="thumbnail" data-v-2978c570=""></span></div>
|
||||
</aside>
|
||||
<main data-v-661f8f0d="">
|
||||
<div class="heading-wrapper" data-v-661f8f0d="">
|
||||
<h1 class="name" data-v-661f8f0d=""> All Songs
|
||||
<!--v-if-->
|
||||
</h1><span class="meta text-secondary" data-v-661f8f0d=""><span>420 songs</span><span>34:17:36</span></span>
|
||||
</div>
|
||||
<div class="song-list-controls" data-testid="song-list-controls" data-v-cee28c08=""><span class="btn-group" uppercased="" data-v-5d6fa912="" data-v-cee28c08=""><button type="button" class="btn-shuffle-all" data-testid="btn-shuffle-all" orange="" title="Shuffle all songs" data-v-27deb898="" data-v-cee28c08=""><br data-testid="icon" icon="[object Object]" data-v-cee28c08=""> All </button><!--v-if--><!--v-if--><!--v-if--><!--v-if--></span>
|
||||
<div class="add-to" data-testid="add-to-menu" tabindex="0" data-v-0351ff38="" data-v-cee28c08="" style="display: none;">
|
||||
<section class="existing-playlists" data-v-0351ff38="">
|
||||
<p data-v-0351ff38="">Add 0 songs to</p>
|
||||
<ul data-v-0351ff38="">
|
||||
<li data-testid="queue" tabindex="0" data-v-0351ff38="">Queue</li>
|
||||
<li class="favorites" data-testid="add-to-favorites" tabindex="0" data-v-0351ff38=""> Favorites </li>
|
||||
</ul>
|
||||
</section>
|
||||
<section class="new-playlist" data-testid="new-playlist" data-v-0351ff38="">
|
||||
<p data-v-0351ff38="">or create a new playlist</p>
|
||||
<form class="form-save form-simple form-new-playlist" data-v-0351ff38=""><input data-testid="new-playlist-name" placeholder="Playlist name" required="" type="text" data-v-0351ff38=""><button type="submit" title="Save" data-v-27deb898="" data-v-0351ff38="">⏎</button></form>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</header><br data-testid="song-list">
|
||||
</section>
|
||||
`;
|
||||
|
|
|
@ -3,15 +3,15 @@
|
|||
exports[`renders 1`] = `
|
||||
<section id="artistWrapper" data-v-dceda15c="">
|
||||
<!--v-if-->
|
||||
<header class="screen-header expanded" data-v-dceda15c="">
|
||||
<aside class="thumbnail-wrapper"><span class="cover" data-testid="album-artist-thumbnail" data-v-901ba52c="" data-v-dceda15c=""><a class="control control-play" href="" role="button" data-v-901ba52c=""><span class="hidden" data-v-901ba52c="">Play all songs by Led Zeppelin</span><span class="icon" data-v-901ba52c=""></span></a></span></aside>
|
||||
<main>
|
||||
<div class="heading-wrapper">
|
||||
<h1 class="name">Led Zeppelin
|
||||
<header class="screen-header expanded" data-v-661f8f0d="" data-v-dceda15c="">
|
||||
<aside class="thumbnail-wrapper" data-v-661f8f0d=""><span class="cover" data-testid="album-artist-thumbnail" data-v-901ba52c="" data-v-dceda15c=""><a class="control control-play" href="" role="button" data-v-901ba52c=""><span class="hidden" data-v-901ba52c="">Play all songs by Led Zeppelin</span><span class="icon" data-v-901ba52c=""></span></a></span></aside>
|
||||
<main data-v-661f8f0d="">
|
||||
<div class="heading-wrapper" data-v-661f8f0d="">
|
||||
<h1 class="name" data-v-661f8f0d="">Led Zeppelin
|
||||
<!--v-if-->
|
||||
</h1><span class="meta text-secondary"><span data-v-dceda15c="">12 albums</span><span data-v-dceda15c="">53 songs</span><span data-v-dceda15c="">11:16:43</span><a class="info" href="" title="View artist information" data-v-dceda15c="">Info</a><a class="download" href="" role="button" title="Download all songs by this artist" data-v-dceda15c=""> Download All </a></span>
|
||||
</h1><span class="meta text-secondary" data-v-661f8f0d=""><span data-v-dceda15c="">12 albums</span><span data-v-dceda15c="">53 songs</span><span data-v-dceda15c="">11:16:43</span><a class="info" href="" title="View artist information" data-v-dceda15c="">Info</a><a class="download" href="" role="button" title="Download all songs by this artist" data-v-dceda15c=""> Download All </a></span>
|
||||
</div>
|
||||
<div class="song-list-controls" data-testid="song-list-controls" data-v-cee28c08="" data-v-dceda15c=""><span class="btn-group" uppercased="" data-v-cee28c08=""><button type="button" class="btn-shuffle-all" data-testid="btn-shuffle-all" orange="" title="Shuffle all songs" data-v-27deb898="" data-v-cee28c08=""><br data-testid="icon" icon="[object Object]" data-v-cee28c08=""> All </button><!--v-if--><!--v-if--><!--v-if--><!--v-if--></span>
|
||||
<div class="song-list-controls" data-testid="song-list-controls" data-v-cee28c08="" data-v-dceda15c=""><span class="btn-group" uppercased="" data-v-5d6fa912="" data-v-cee28c08=""><button type="button" class="btn-shuffle-all" data-testid="btn-shuffle-all" orange="" title="Shuffle all songs" data-v-27deb898="" data-v-cee28c08=""><br data-testid="icon" icon="[object Object]" data-v-cee28c08=""> All </button><!--v-if--><!--v-if--><!--v-if--><!--v-if--></span>
|
||||
<div class="add-to" data-testid="add-to-menu" tabindex="0" data-v-0351ff38="" data-v-cee28c08="" style="display: none;">
|
||||
<section class="existing-playlists" data-v-0351ff38="">
|
||||
<p data-v-0351ff38="">Add 0 songs to</p>
|
||||
|
|
|
@ -2,11 +2,11 @@
|
|||
|
||||
exports[`renders 1`] = `
|
||||
<section id="settingsWrapper">
|
||||
<header class="screen-header expanded">
|
||||
<aside class="thumbnail-wrapper"></aside>
|
||||
<main>
|
||||
<div class="heading-wrapper">
|
||||
<h1 class="name">Settings</h1><span class="meta text-secondary"></span>
|
||||
<header class="screen-header expanded" data-v-661f8f0d="">
|
||||
<aside class="thumbnail-wrapper" data-v-661f8f0d=""></aside>
|
||||
<main data-v-661f8f0d="">
|
||||
<div class="heading-wrapper" data-v-661f8f0d="">
|
||||
<h1 class="name" data-v-661f8f0d="">Settings</h1><span class="meta text-secondary" data-v-661f8f0d=""></span>
|
||||
</div>
|
||||
</main>
|
||||
</header>
|
||||
|
|
|
@ -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>
|
||||
|
||||
|
|
|
@ -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">
|
||||
|
|
|
@ -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')
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
|
@ -17,18 +17,18 @@ new class extends UnitTestCase {
|
|||
}
|
||||
|
||||
private async renderComponent (_songs?: Song | Song[]) {
|
||||
songs = arrayify(_songs || factory<Song[]>('song', 5))
|
||||
songs = arrayify(_songs || factory<Song>('song', 5))
|
||||
|
||||
const rendered = this.render(SongContextMenu)
|
||||
eventBus.emit('SONG_CONTEXT_MENU_REQUESTED', { pageX: 420, pageY: 69 }, songs)
|
||||
eventBus.emit('SONG_CONTEXT_MENU_REQUESTED', { pageX: 420, pageY: 42 }, songs)
|
||||
await this.tick(2)
|
||||
|
||||
return rendered
|
||||
}
|
||||
|
||||
private fillQueue () {
|
||||
queueStore.state.songs = factory<Song[]>('song', 5)
|
||||
queueStore.state.current = queueStore.state.songs[0]
|
||||
queueStore.state.songs = factory<Song>('song', 5)
|
||||
queueStore.state.songs[2].playback_state = 'Playing'
|
||||
}
|
||||
|
||||
protected test () {
|
||||
|
@ -138,7 +138,7 @@ new class extends UnitTestCase {
|
|||
})
|
||||
|
||||
it('lists and adds to existing playlist', async () => {
|
||||
playlistStore.state.playlists = factory<Playlist[]>('playlist', 3)
|
||||
playlistStore.state.playlists = factory<Playlist>('playlist', 3)
|
||||
const addMock = this.mock(playlistStore, 'addSongs')
|
||||
this.mock(MessageToasterStub.value, 'success')
|
||||
const { queryByText, getByText } = await this.renderComponent()
|
||||
|
@ -151,7 +151,7 @@ new class extends UnitTestCase {
|
|||
})
|
||||
|
||||
it('does not list smart playlists', async () => {
|
||||
playlistStore.state.playlists = factory<Playlist[]>('playlist', 3)
|
||||
playlistStore.state.playlists = factory<Playlist>('playlist', 3)
|
||||
playlistStore.state.playlists.push(factory.states('smart')<Playlist>('playlist', { name: 'My Smart Playlist' }))
|
||||
|
||||
const { queryByText } = await this.renderComponent()
|
||||
|
|
|
@ -67,7 +67,7 @@ const playlists = toRef(playlistStore.state, 'playlists')
|
|||
const allowDownload = toRef(commonStore.state, 'allow_download')
|
||||
const user = toRef(userStore.state, 'current')
|
||||
const queue = toRef(queueStore.state, 'songs')
|
||||
const currentSong = toRef(queueStore.state, 'current')
|
||||
const currentSong = queueStore.current
|
||||
|
||||
const onlyOneSongSelected = computed(() => songs.value.length === 1)
|
||||
const firstSongPlaying = computed(() => songs.value.length ? songs.value[0].playback_state === 'Playing' : false)
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
import { expect, it } from 'vitest'
|
||||
import { ref } from 'vue'
|
||||
import { expect, it } from 'vitest'
|
||||
import { fireEvent } from '@testing-library/vue'
|
||||
import factory from '@/__tests__/factory'
|
||||
import UnitTestCase from '@/__tests__/UnitTestCase'
|
||||
import { arrayify } from '@/utils'
|
||||
|
@ -12,7 +13,6 @@ import {
|
|||
SongsKey
|
||||
} from '@/symbols'
|
||||
import SongList from './SongList.vue'
|
||||
import { fireEvent } from '@testing-library/vue'
|
||||
|
||||
let songs: Song[]
|
||||
|
||||
|
@ -27,18 +27,21 @@ new class extends UnitTestCase {
|
|||
) {
|
||||
songs = arrayify(_songs)
|
||||
|
||||
const sortFieldRef = ref(sortField)
|
||||
const sortOrderRef = ref(sortOrder)
|
||||
|
||||
return this.render(SongList, {
|
||||
global: {
|
||||
stubs: {
|
||||
VirtualScroller: this.stub('virtual-scroller')
|
||||
},
|
||||
provide: {
|
||||
[SongsKey]: [ref(songs)],
|
||||
[SelectedSongsKey]: [ref(selectedSongs), value => selectedSongs = value],
|
||||
[SongListTypeKey]: [ref(type)],
|
||||
[SongListConfigKey]: [config],
|
||||
[SongListSortFieldKey]: [ref(sortField), value => sortField = value],
|
||||
[SongListSortOrderKey]: [ref(sortOrder), value => sortOrder = value]
|
||||
[<symbol>SongsKey]: [ref(songs)],
|
||||
[<symbol>SelectedSongsKey]: [ref(selectedSongs), value => (selectedSongs = value)],
|
||||
[<symbol>SongListTypeKey]: [ref(type)],
|
||||
[<symbol>SongListConfigKey]: [config],
|
||||
[<symbol>SongListSortFieldKey]: [sortFieldRef, value => (sortFieldRef.value = value)],
|
||||
[<symbol>SongListSortOrderKey]: [sortOrderRef, value => (sortOrderRef.value = value)]
|
||||
}
|
||||
}
|
||||
})
|
||||
|
@ -46,24 +49,24 @@ new class extends UnitTestCase {
|
|||
|
||||
protected test () {
|
||||
it('renders', async () => {
|
||||
const { html } = this.renderComponent(factory<Song[]>('song', 5))
|
||||
const { html } = this.renderComponent(factory<Song>('song', 5))
|
||||
expect(html()).toMatchSnapshot()
|
||||
})
|
||||
|
||||
it.each([
|
||||
it.each<[SongListSortField, string]>([
|
||||
['track', 'header-track-number'],
|
||||
['title', 'header-title'],
|
||||
['album_name', 'header-album'],
|
||||
['length', 'header-length'],
|
||||
['artist_name', 'header-artist']
|
||||
])('sorts by %s upon %s clicked', async (field: SongListSortField, testId: string) => {
|
||||
const { getByTestId, emitted } = this.renderComponent(factory<Song[]>('song', 5))
|
||||
])('sorts by %s upon %s clicked', async (field, testId) => {
|
||||
const { getByTestId, emitted } = this.renderComponent(factory<Song>('song', 5))
|
||||
|
||||
await fireEvent.click(getByTestId(testId))
|
||||
expect(emitted().sort[0]).toBeTruthy([field, 'asc'])
|
||||
expect(emitted().sort[0]).toEqual([field, 'desc'])
|
||||
|
||||
await fireEvent.click(getByTestId(testId))
|
||||
expect(emitted().sort[0]).toBeTruthy([field, 'desc'])
|
||||
expect(emitted().sort[1]).toEqual([field, 'asc'])
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
@ -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>
|
||||
`;
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -40,7 +40,7 @@
|
|||
}
|
||||
|
||||
.text {
|
||||
font-size: 2em;
|
||||
font-size: 2rem;
|
||||
font-weight: 200;
|
||||
line-height: 1.3;
|
||||
|
||||
|
|
|
@ -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;
|
||||
|
||||
|
|
|
@ -1,3 +1,3 @@
|
|||
// Vitest Snapshot v1
|
||||
|
||||
exports[`renders 1`] = `<span class="btn-group"><button type="button" data-v-27deb898="">Green</button><button type="button" data-v-27deb898="">Orange</button><button type="button" data-v-27deb898="">Blue</button></span>`;
|
||||
exports[`renders 1`] = `<span class="btn-group" data-v-5d6fa912=""><button type="button" data-v-27deb898="">Green</button><button type="button" data-v-27deb898="">Orange</button><button type="button" data-v-27deb898="">Blue</button></span>`;
|
||||
|
|
|
@ -1,11 +1,11 @@
|
|||
// Vitest Snapshot v1
|
||||
|
||||
exports[`renders 1`] = `
|
||||
<header class="screen-header expanded">
|
||||
<aside class="thumbnail-wrapper"><img src="https://placekitten.com/200/300"></aside>
|
||||
<main>
|
||||
<div class="heading-wrapper">
|
||||
<h1 class="name">This Header</h1><span class="meta text-secondary"><p>Some meta</p></span>
|
||||
<header class="screen-header expanded" data-v-661f8f0d="">
|
||||
<aside class="thumbnail-wrapper" data-v-661f8f0d=""><img src="https://placekitten.com/200/300"></aside>
|
||||
<main data-v-661f8f0d="">
|
||||
<div class="heading-wrapper" data-v-661f8f0d="">
|
||||
<h1 class="name" data-v-661f8f0d="">This Header</h1><span class="meta text-secondary" data-v-661f8f0d=""><p>Some meta</p></span>
|
||||
</div>
|
||||
<nav>Some controls</nav>
|
||||
</main>
|
||||
|
|
|
@ -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
Loading…
Reference in a new issue