feat(ui): add 'Show in Playlist' context menu (#4139)
* Update song playlist menu and endpoint * feat(ui): show submenu on click, not on hover Signed-off-by: Deluan <deluan@navidrome.org> * feat(ui): integrate dataProvider for fetching playlists in song context menu Signed-off-by: Deluan <deluan@navidrome.org> * feat(ui): update song context menu to use dataProvider for fetching playlists and inspecting songs Signed-off-by: Deluan <deluan@navidrome.org> * feat(ui): stop event propagation when closing playlist submenu Signed-off-by: Deluan <deluan@navidrome.org> * feat(ui): add 'show in playlist' option to options object Signed-off-by: Deluan <deluan@navidrome.org> --------- Signed-off-by: Deluan <deluan@navidrome.org>
This commit is contained in:
@@ -101,6 +101,7 @@ type PlaylistRepository interface {
|
|||||||
FindByPath(path string) (*Playlist, error)
|
FindByPath(path string) (*Playlist, error)
|
||||||
Delete(id string) error
|
Delete(id string) error
|
||||||
Tracks(playlistId string, refreshSmartPlaylist bool) PlaylistTrackRepository
|
Tracks(playlistId string, refreshSmartPlaylist bool) PlaylistTrackRepository
|
||||||
|
GetPlaylists(mediaFileId string) (Playlists, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
type PlaylistTrack struct {
|
type PlaylistTrack struct {
|
||||||
|
|||||||
@@ -197,6 +197,25 @@ func (r *playlistRepository) GetAll(options ...model.QueryOptions) (model.Playli
|
|||||||
return playlists, err
|
return playlists, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (r *playlistRepository) GetPlaylists(mediaFileId string) (model.Playlists, error) {
|
||||||
|
sel := r.selectPlaylist(model.QueryOptions{Sort: "name"}).
|
||||||
|
Join("playlist_tracks on playlist.id = playlist_tracks.playlist_id").
|
||||||
|
Where(And{Eq{"playlist_tracks.media_file_id": mediaFileId}, r.userFilter()})
|
||||||
|
var res []dbPlaylist
|
||||||
|
err := r.queryAll(sel, &res)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, model.ErrNotFound) {
|
||||||
|
return model.Playlists{}, nil
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
playlists := make(model.Playlists, len(res))
|
||||||
|
for i, p := range res {
|
||||||
|
playlists[i] = p.Playlist
|
||||||
|
}
|
||||||
|
return playlists, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (r *playlistRepository) selectPlaylist(options ...model.QueryOptions) SelectBuilder {
|
func (r *playlistRepository) selectPlaylist(options ...model.QueryOptions) SelectBuilder {
|
||||||
return r.newSelect(options...).Join("user on user.id = owner_id").
|
return r.newSelect(options...).Join("user on user.id = owner_id").
|
||||||
Columns(r.tableName+".*", "user.user_name as owner_name")
|
Columns(r.tableName+".*", "user.user_name as owner_name")
|
||||||
|
|||||||
@@ -112,6 +112,21 @@ var _ = Describe("PlaylistRepository", func() {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
Describe("GetPlaylists", func() {
|
||||||
|
It("returns playlists for a track", func() {
|
||||||
|
pls, err := repo.GetPlaylists(songRadioactivity.ID)
|
||||||
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
Expect(pls).To(HaveLen(1))
|
||||||
|
Expect(pls[0].ID).To(Equal(plsBest.ID))
|
||||||
|
})
|
||||||
|
|
||||||
|
It("returns empty when none", func() {
|
||||||
|
pls, err := repo.GetPlaylists("9999")
|
||||||
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
Expect(pls).To(HaveLen(0))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
Context("Smart Playlists", func() {
|
Context("Smart Playlists", func() {
|
||||||
var rules *criteria.Criteria
|
var rules *criteria.Criteria
|
||||||
BeforeEach(func() {
|
BeforeEach(func() {
|
||||||
|
|||||||
@@ -59,6 +59,7 @@ func (n *Router) routes() http.Handler {
|
|||||||
|
|
||||||
n.addPlaylistRoute(r)
|
n.addPlaylistRoute(r)
|
||||||
n.addPlaylistTrackRoute(r)
|
n.addPlaylistTrackRoute(r)
|
||||||
|
n.addSongPlaylistsRoute(r)
|
||||||
n.addMissingFilesRoute(r)
|
n.addMissingFilesRoute(r)
|
||||||
n.addInspectRoute(r)
|
n.addInspectRoute(r)
|
||||||
n.addConfigRoute(r)
|
n.addConfigRoute(r)
|
||||||
@@ -142,6 +143,15 @@ func (n *Router) addPlaylistTrackRoute(r chi.Router) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (n *Router) addSongPlaylistsRoute(r chi.Router) {
|
||||||
|
r.Route("/song/{id}", func(r chi.Router) {
|
||||||
|
r.Use(server.URLParamsMiddleware)
|
||||||
|
r.Get("/playlists", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
getSongPlaylists(n.ds)(w, r)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
func (n *Router) addMissingFilesRoute(r chi.Router) {
|
func (n *Router) addMissingFilesRoute(r chi.Router) {
|
||||||
r.Route("/missing", func(r chi.Router) {
|
r.Route("/missing", func(r chi.Router) {
|
||||||
n.RX(r, "/", newMissingRepository(n.ds), false)
|
n.RX(r, "/", newMissingRepository(n.ds), false)
|
||||||
|
|||||||
@@ -207,3 +207,21 @@ func reorderItem(ds model.DataStore) http.HandlerFunc {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func getSongPlaylists(ds model.DataStore) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
p := req.Params(r)
|
||||||
|
trackId, _ := p.String(":id")
|
||||||
|
playlists, err := ds.Playlist(r.Context()).GetPlaylists(trackId)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
data, err := json.Marshal(playlists)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_, _ = w.Write(data)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,7 +1,12 @@
|
|||||||
import React, { useState } from 'react'
|
import React, { useState } from 'react'
|
||||||
import PropTypes from 'prop-types'
|
import PropTypes from 'prop-types'
|
||||||
import { useDispatch } from 'react-redux'
|
import { useDispatch } from 'react-redux'
|
||||||
import { useNotify, usePermissions, useTranslate } from 'react-admin'
|
import {
|
||||||
|
useNotify,
|
||||||
|
usePermissions,
|
||||||
|
useTranslate,
|
||||||
|
useDataProvider,
|
||||||
|
} from 'react-admin'
|
||||||
import { IconButton, Menu, MenuItem } from '@material-ui/core'
|
import { IconButton, Menu, MenuItem } from '@material-ui/core'
|
||||||
import { makeStyles } from '@material-ui/core/styles'
|
import { makeStyles } from '@material-ui/core/styles'
|
||||||
import MoreVertIcon from '@material-ui/icons/MoreVert'
|
import MoreVertIcon from '@material-ui/icons/MoreVert'
|
||||||
@@ -20,7 +25,7 @@ import {
|
|||||||
import { LoveButton } from './LoveButton'
|
import { LoveButton } from './LoveButton'
|
||||||
import config from '../config'
|
import config from '../config'
|
||||||
import { formatBytes } from '../utils'
|
import { formatBytes } from '../utils'
|
||||||
import { httpClient } from '../dataProvider'
|
import { useRedirect } from 'react-admin'
|
||||||
|
|
||||||
const useStyles = makeStyles({
|
const useStyles = makeStyles({
|
||||||
noWrap: {
|
noWrap: {
|
||||||
@@ -57,8 +62,13 @@ export const SongContextMenu = ({
|
|||||||
const dispatch = useDispatch()
|
const dispatch = useDispatch()
|
||||||
const translate = useTranslate()
|
const translate = useTranslate()
|
||||||
const notify = useNotify()
|
const notify = useNotify()
|
||||||
|
const dataProvider = useDataProvider()
|
||||||
const [anchorEl, setAnchorEl] = useState(null)
|
const [anchorEl, setAnchorEl] = useState(null)
|
||||||
|
const [playlistAnchorEl, setPlaylistAnchorEl] = useState(null)
|
||||||
|
const [playlists, setPlaylists] = useState([])
|
||||||
|
const [playlistsLoaded, setPlaylistsLoaded] = useState(false)
|
||||||
const { permissions } = usePermissions()
|
const { permissions } = usePermissions()
|
||||||
|
const redirect = useRedirect()
|
||||||
|
|
||||||
const options = {
|
const options = {
|
||||||
playNow: {
|
playNow: {
|
||||||
@@ -87,6 +97,15 @@ export const SongContextMenu = ({
|
|||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
showInPlaylist: {
|
||||||
|
enabled: true,
|
||||||
|
label:
|
||||||
|
translate('resources.song.actions.showInPlaylist') +
|
||||||
|
(playlists.length > 0 ? ' ►' : ''),
|
||||||
|
action: (record, e) => {
|
||||||
|
setPlaylistAnchorEl(e.currentTarget)
|
||||||
|
},
|
||||||
|
},
|
||||||
share: {
|
share: {
|
||||||
enabled: config.enableSharing,
|
enabled: config.enableSharing,
|
||||||
label: translate('ra.action.share'),
|
label: translate('ra.action.share'),
|
||||||
@@ -113,8 +132,8 @@ export const SongContextMenu = ({
|
|||||||
if (permissions === 'admin' && !record.missing) {
|
if (permissions === 'admin' && !record.missing) {
|
||||||
try {
|
try {
|
||||||
let id = record.mediaFileId ?? record.id
|
let id = record.mediaFileId ?? record.id
|
||||||
const data = await httpClient(`/api/inspect?id=${id}`)
|
const data = await dataProvider.inspect(id)
|
||||||
fullRecord = { ...record, rawTags: data.json.rawTags }
|
fullRecord = { ...record, rawTags: data.data.rawTags }
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
notify(
|
notify(
|
||||||
translate('ra.notification.http_error') + ': ' + error.message,
|
translate('ra.notification.http_error') + ': ' + error.message,
|
||||||
@@ -134,6 +153,21 @@ export const SongContextMenu = ({
|
|||||||
|
|
||||||
const handleClick = (e) => {
|
const handleClick = (e) => {
|
||||||
setAnchorEl(e.currentTarget)
|
setAnchorEl(e.currentTarget)
|
||||||
|
if (!playlistsLoaded) {
|
||||||
|
const id = record.mediaFileId || record.id
|
||||||
|
dataProvider
|
||||||
|
.getPlaylists(id)
|
||||||
|
.then((res) => {
|
||||||
|
setPlaylists(res.data)
|
||||||
|
setPlaylistsLoaded(true)
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.error('Failed to fetch playlists:', error)
|
||||||
|
setPlaylists([])
|
||||||
|
setPlaylistsLoaded(true)
|
||||||
|
})
|
||||||
|
}
|
||||||
e.stopPropagation()
|
e.stopPropagation()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -144,12 +178,39 @@ export const SongContextMenu = ({
|
|||||||
|
|
||||||
const handleItemClick = (e) => {
|
const handleItemClick = (e) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
setAnchorEl(null)
|
|
||||||
const key = e.target.getAttribute('value')
|
const key = e.target.getAttribute('value')
|
||||||
options[key].action(record)
|
const action = options[key].action
|
||||||
|
|
||||||
|
if (key === 'showInPlaylist') {
|
||||||
|
// For showInPlaylist, we keep the main menu open and show submenu
|
||||||
|
action(record, e)
|
||||||
|
} else {
|
||||||
|
// For other actions, close the main menu
|
||||||
|
setAnchorEl(null)
|
||||||
|
action(record)
|
||||||
|
}
|
||||||
e.stopPropagation()
|
e.stopPropagation()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handlePlaylistClose = (e) => {
|
||||||
|
setPlaylistAnchorEl(null)
|
||||||
|
if (e) {
|
||||||
|
e.stopPropagation()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleMainMenuClose = (e) => {
|
||||||
|
setAnchorEl(null)
|
||||||
|
setPlaylistAnchorEl(null) // Close both menus
|
||||||
|
e.stopPropagation()
|
||||||
|
}
|
||||||
|
|
||||||
|
const handlePlaylistClick = (id, e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
redirect(`/playlist/${id}/show`)
|
||||||
|
handlePlaylistClose()
|
||||||
|
}
|
||||||
|
|
||||||
const open = Boolean(anchorEl)
|
const open = Boolean(anchorEl)
|
||||||
|
|
||||||
if (!record) {
|
if (!record) {
|
||||||
@@ -170,17 +231,41 @@ export const SongContextMenu = ({
|
|||||||
id={'menu' + record.id}
|
id={'menu' + record.id}
|
||||||
anchorEl={anchorEl}
|
anchorEl={anchorEl}
|
||||||
open={open}
|
open={open}
|
||||||
onClose={handleClose}
|
onClose={handleMainMenuClose}
|
||||||
>
|
>
|
||||||
{Object.keys(options).map(
|
{Object.keys(options).map(
|
||||||
(key) =>
|
(key) =>
|
||||||
options[key].enabled && (
|
options[key].enabled && (
|
||||||
<MenuItem value={key} key={key} onClick={handleItemClick}>
|
<MenuItem
|
||||||
|
value={key}
|
||||||
|
key={key}
|
||||||
|
onClick={handleItemClick}
|
||||||
|
disabled={key === 'showInPlaylist' && !playlists.length}
|
||||||
|
>
|
||||||
{options[key].label}
|
{options[key].label}
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
),
|
),
|
||||||
)}
|
)}
|
||||||
</Menu>
|
</Menu>
|
||||||
|
<Menu
|
||||||
|
anchorEl={playlistAnchorEl}
|
||||||
|
open={Boolean(playlistAnchorEl)}
|
||||||
|
onClose={handlePlaylistClose}
|
||||||
|
anchorOrigin={{
|
||||||
|
vertical: 'top',
|
||||||
|
horizontal: 'right',
|
||||||
|
}}
|
||||||
|
transformOrigin={{
|
||||||
|
vertical: 'top',
|
||||||
|
horizontal: 'left',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{playlists.map((p) => (
|
||||||
|
<MenuItem key={p.id} onClick={(e) => handlePlaylistClick(p.id, e)}>
|
||||||
|
{p.name}
|
||||||
|
</MenuItem>
|
||||||
|
))}
|
||||||
|
</Menu>
|
||||||
</span>
|
</span>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,82 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import { render, fireEvent, screen, waitFor } from '@testing-library/react'
|
||||||
|
import { TestContext } from 'ra-test'
|
||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||||
|
import { SongContextMenu } from './SongContextMenu'
|
||||||
|
|
||||||
|
vi.mock('../dataProvider', () => ({
|
||||||
|
httpClient: vi.fn(),
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('react-redux', () => ({ useDispatch: () => vi.fn() }))
|
||||||
|
|
||||||
|
vi.mock('react-admin', async (importOriginal) => {
|
||||||
|
const actual = await importOriginal()
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
useRedirect: () => (url) => {
|
||||||
|
window.location.hash = `#${url}`
|
||||||
|
},
|
||||||
|
useDataProvider: () => ({
|
||||||
|
getPlaylists: vi.fn().mockResolvedValue({
|
||||||
|
data: [{ id: 'pl1', name: 'Pl 1' }],
|
||||||
|
}),
|
||||||
|
inspect: vi.fn().mockResolvedValue({
|
||||||
|
data: { rawTags: {} },
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('SongContextMenu', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks()
|
||||||
|
window.location.hash = ''
|
||||||
|
})
|
||||||
|
|
||||||
|
it('navigates to playlist when selected', async () => {
|
||||||
|
render(
|
||||||
|
<TestContext>
|
||||||
|
<SongContextMenu record={{ id: 'song1', size: 1 }} resource="song" />
|
||||||
|
</TestContext>,
|
||||||
|
)
|
||||||
|
fireEvent.click(screen.getAllByRole('button')[1])
|
||||||
|
await waitFor(() =>
|
||||||
|
screen.getByText(/resources\.song\.actions\.showInPlaylist/),
|
||||||
|
)
|
||||||
|
fireEvent.click(
|
||||||
|
screen.getByText(/resources\.song\.actions\.showInPlaylist/),
|
||||||
|
)
|
||||||
|
await waitFor(() => screen.getByText('Pl 1'))
|
||||||
|
fireEvent.click(screen.getByText('Pl 1'))
|
||||||
|
expect(window.location.hash).toBe('#/playlist/pl1/show')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('stops event propagation when playlist submenu is closed', async () => {
|
||||||
|
const mockOnClick = vi.fn()
|
||||||
|
render(
|
||||||
|
<TestContext>
|
||||||
|
<div onClick={mockOnClick}>
|
||||||
|
<SongContextMenu record={{ id: 'song1', size: 1 }} resource="song" />
|
||||||
|
</div>
|
||||||
|
</TestContext>,
|
||||||
|
)
|
||||||
|
|
||||||
|
// Open main menu
|
||||||
|
fireEvent.click(screen.getAllByRole('button')[1])
|
||||||
|
await waitFor(() =>
|
||||||
|
screen.getByText(/resources\.song\.actions\.showInPlaylist/),
|
||||||
|
)
|
||||||
|
|
||||||
|
// Open playlist submenu
|
||||||
|
fireEvent.click(
|
||||||
|
screen.getByText(/resources\.song\.actions\.showInPlaylist/),
|
||||||
|
)
|
||||||
|
await waitFor(() => screen.getByText('Pl 1'))
|
||||||
|
|
||||||
|
// Click outside the playlist submenu (should close it without triggering parent click)
|
||||||
|
fireEvent.click(document.body)
|
||||||
|
|
||||||
|
expect(mockOnClick).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -90,6 +90,16 @@ const wrapperDataProvider = {
|
|||||||
body: JSON.stringify(data),
|
body: JSON.stringify(data),
|
||||||
}).then(({ json }) => ({ data: json }))
|
}).then(({ json }) => ({ data: json }))
|
||||||
},
|
},
|
||||||
|
getPlaylists: (songId) => {
|
||||||
|
return httpClient(`${REST_URL}/song/${songId}/playlists`).then(
|
||||||
|
({ json }) => ({ data: json }),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
inspect: (songId) => {
|
||||||
|
return httpClient(`${REST_URL}/inspect?id=${songId}`).then(({ json }) => ({
|
||||||
|
data: json,
|
||||||
|
}))
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
export default wrapperDataProvider
|
export default wrapperDataProvider
|
||||||
|
|||||||
@@ -41,6 +41,7 @@
|
|||||||
"addToQueue": "Play Later",
|
"addToQueue": "Play Later",
|
||||||
"playNow": "Play Now",
|
"playNow": "Play Now",
|
||||||
"addToPlaylist": "Add to Playlist",
|
"addToPlaylist": "Add to Playlist",
|
||||||
|
"showInPlaylist": "Show in Playlist",
|
||||||
"shuffleAll": "Shuffle All",
|
"shuffleAll": "Shuffle All",
|
||||||
"download": "Download",
|
"download": "Download",
|
||||||
"playNext": "Play Next",
|
"playNext": "Play Next",
|
||||||
|
|||||||
Reference in New Issue
Block a user