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:
Deluan Quintão
2025-05-30 21:26:35 -04:00
committed by GitHub
parent 6dd98e0bed
commit ded8cf236e
9 changed files with 249 additions and 8 deletions
+93 -8
View File
@@ -1,7 +1,12 @@
import React, { useState } from 'react'
import PropTypes from 'prop-types'
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 { makeStyles } from '@material-ui/core/styles'
import MoreVertIcon from '@material-ui/icons/MoreVert'
@@ -20,7 +25,7 @@ import {
import { LoveButton } from './LoveButton'
import config from '../config'
import { formatBytes } from '../utils'
import { httpClient } from '../dataProvider'
import { useRedirect } from 'react-admin'
const useStyles = makeStyles({
noWrap: {
@@ -57,8 +62,13 @@ export const SongContextMenu = ({
const dispatch = useDispatch()
const translate = useTranslate()
const notify = useNotify()
const dataProvider = useDataProvider()
const [anchorEl, setAnchorEl] = useState(null)
const [playlistAnchorEl, setPlaylistAnchorEl] = useState(null)
const [playlists, setPlaylists] = useState([])
const [playlistsLoaded, setPlaylistsLoaded] = useState(false)
const { permissions } = usePermissions()
const redirect = useRedirect()
const options = {
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: {
enabled: config.enableSharing,
label: translate('ra.action.share'),
@@ -113,8 +132,8 @@ export const SongContextMenu = ({
if (permissions === 'admin' && !record.missing) {
try {
let id = record.mediaFileId ?? record.id
const data = await httpClient(`/api/inspect?id=${id}`)
fullRecord = { ...record, rawTags: data.json.rawTags }
const data = await dataProvider.inspect(id)
fullRecord = { ...record, rawTags: data.data.rawTags }
} catch (error) {
notify(
translate('ra.notification.http_error') + ': ' + error.message,
@@ -134,6 +153,21 @@ export const SongContextMenu = ({
const handleClick = (e) => {
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()
}
@@ -144,12 +178,39 @@ export const SongContextMenu = ({
const handleItemClick = (e) => {
e.preventDefault()
setAnchorEl(null)
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()
}
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)
if (!record) {
@@ -170,17 +231,41 @@ export const SongContextMenu = ({
id={'menu' + record.id}
anchorEl={anchorEl}
open={open}
onClose={handleClose}
onClose={handleMainMenuClose}
>
{Object.keys(options).map(
(key) =>
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}
</MenuItem>
),
)}
</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>
)
}
+82
View File
@@ -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()
})
})