mirror of
https://github.com/navidrome/navidrome.git
synced 2026-10-28 14:17:10 +01:00
feat: enhance library selection with master checkbox functionality
Added a master checkbox to the SelectLibraryInput component, allowing users to select or deselect all libraries at once. This improves user experience by simplifying the selection process when multiple libraries are available. Additionally, updated translations in the en.json file to include a new message for selecting all libraries, ensuring consistency in user interface messaging. Signed-off-by: Deluan <deluan@navidrome.org>
This commit is contained in:
parent
cf676f2a08
commit
0b2d728b21
5 changed files with 193 additions and 14 deletions
|
|
@ -8,8 +8,9 @@ import {
|
|||
ListItemIcon,
|
||||
ListItemText,
|
||||
Typography,
|
||||
Box,
|
||||
} from '@material-ui/core'
|
||||
import { useGetList } from 'react-admin'
|
||||
import { useGetList, useTranslate } from 'react-admin'
|
||||
import PropTypes from 'prop-types'
|
||||
import { makeStyles } from '@material-ui/core'
|
||||
|
||||
|
|
@ -18,6 +19,17 @@ const useStyles = makeStyles((theme) => ({
|
|||
width: '960px', // MD breakpoint width
|
||||
maxWidth: '100%',
|
||||
},
|
||||
headerContainer: {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
marginBottom: theme.spacing(1),
|
||||
paddingLeft: theme.spacing(1),
|
||||
},
|
||||
masterCheckbox: {
|
||||
padding: '7px', // Match the default Material-UI checkbox padding
|
||||
marginLeft: '-9px', // Negative margin to align with FormControlLabel checkboxes
|
||||
marginRight: theme.spacing(1),
|
||||
},
|
||||
libraryList: {
|
||||
height: '120px', // 3 rows of libraries
|
||||
overflow: 'auto',
|
||||
|
|
@ -72,6 +84,7 @@ const LibraryListItem = ({ library, isSelected, onToggle }) => {
|
|||
|
||||
export const SelectLibraryInput = ({ onChange, value = [] }) => {
|
||||
const classes = useStyles()
|
||||
const translate = useTranslate()
|
||||
const [selectedLibraryIds, setSelectedLibraryIds] = useState([])
|
||||
|
||||
const { ids, data } = useGetList(
|
||||
|
|
@ -109,8 +122,35 @@ export const SelectLibraryInput = ({ onChange, value = [] }) => {
|
|||
onChange(newSelection)
|
||||
}
|
||||
|
||||
const handleMasterCheckboxChange = () => {
|
||||
const isAllSelected = selectedLibraryIds.length === options.length
|
||||
const newSelection = isAllSelected ? [] : options.map((lib) => lib.id)
|
||||
|
||||
setSelectedLibraryIds(newSelection)
|
||||
onChange(newSelection)
|
||||
}
|
||||
|
||||
const selectedCount = selectedLibraryIds.length
|
||||
const totalCount = options.length
|
||||
const isAllSelected = selectedCount === totalCount && totalCount > 0
|
||||
const isIndeterminate = selectedCount > 0 && selectedCount < totalCount
|
||||
|
||||
return (
|
||||
<div className={classes.root}>
|
||||
{options.length > 1 && (
|
||||
<Box className={classes.headerContainer}>
|
||||
<Checkbox
|
||||
checked={isAllSelected}
|
||||
indeterminate={isIndeterminate}
|
||||
onChange={handleMasterCheckboxChange}
|
||||
size="small"
|
||||
className={classes.masterCheckbox}
|
||||
/>
|
||||
<Typography variant="body2">
|
||||
{translate('resources.user.message.selectAllLibraries')}
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
<List className={classes.libraryList}>
|
||||
{options.length === 0 ? (
|
||||
<EmptyLibraryMessage />
|
||||
|
|
|
|||
|
|
@ -4,9 +4,50 @@ import { SelectLibraryInput } from './SelectLibraryInput'
|
|||
import { useGetList } from 'react-admin'
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
|
||||
|
||||
// Mock Material-UI components
|
||||
vi.mock('@material-ui/core', () => ({
|
||||
List: ({ children }) => <div>{children}</div>,
|
||||
ListItem: ({ children, button, onClick, dense, className }) => (
|
||||
<button onClick={onClick} className={className}>
|
||||
{children}
|
||||
</button>
|
||||
),
|
||||
ListItemIcon: ({ children }) => <span>{children}</span>,
|
||||
ListItemText: ({ primary }) => <span>{primary}</span>,
|
||||
Typography: ({ children, variant }) => <span>{children}</span>,
|
||||
Box: ({ children, className }) => <div className={className}>{children}</div>,
|
||||
Checkbox: ({
|
||||
checked,
|
||||
indeterminate,
|
||||
onChange,
|
||||
size,
|
||||
className,
|
||||
...props
|
||||
}) => (
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={checked}
|
||||
ref={(el) => {
|
||||
if (el) el.indeterminate = indeterminate
|
||||
}}
|
||||
onChange={onChange}
|
||||
className={className}
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
makeStyles: () => () => ({}),
|
||||
}))
|
||||
|
||||
// Mock Material-UI icons
|
||||
vi.mock('@material-ui/icons', () => ({
|
||||
CheckBox: () => <span>☑</span>,
|
||||
CheckBoxOutlineBlank: () => <span>☐</span>,
|
||||
}))
|
||||
|
||||
// Mock the react-admin hook
|
||||
vi.mock('react-admin', () => ({
|
||||
useGetList: vi.fn(),
|
||||
useTranslate: vi.fn(() => (key) => key), // Simple translation mock
|
||||
}))
|
||||
|
||||
describe('<SelectLibraryInput />', () => {
|
||||
|
|
@ -60,8 +101,9 @@ describe('<SelectLibraryInput />', () => {
|
|||
})
|
||||
render(<SelectLibraryInput onChange={mockOnChange} value={[]} />)
|
||||
|
||||
// First test - click to select
|
||||
fireEvent.click(screen.getAllByRole('button')[0]) // Click first list item
|
||||
// Find the library buttons by their text content
|
||||
const library1Button = screen.getByText('Library 1').closest('button')
|
||||
fireEvent.click(library1Button)
|
||||
expect(mockOnChange).toHaveBeenCalledWith(['1'])
|
||||
|
||||
// Clean up to avoid DOM duplication
|
||||
|
|
@ -75,8 +117,11 @@ describe('<SelectLibraryInput />', () => {
|
|||
})
|
||||
render(<SelectLibraryInput onChange={mockOnChange} value={['1']} />)
|
||||
|
||||
// Second test - click to deselect
|
||||
fireEvent.click(screen.getAllByRole('button')[0]) // Click first list item again
|
||||
// Find the library button again and click to deselect
|
||||
const library1ButtonDeselect = screen
|
||||
.getByText('Library 1')
|
||||
.closest('button')
|
||||
fireEvent.click(library1ButtonDeselect)
|
||||
expect(mockOnChange).toHaveBeenCalledWith([])
|
||||
})
|
||||
|
||||
|
|
@ -96,8 +141,9 @@ describe('<SelectLibraryInput />', () => {
|
|||
|
||||
// Check that checkbox for Library 1 is checked
|
||||
const checkboxes = screen.getAllByRole('checkbox')
|
||||
expect(checkboxes[0].checked).toBe(true)
|
||||
expect(checkboxes[1].checked).toBe(false)
|
||||
// With master checkbox, individual checkboxes start at index 1
|
||||
expect(checkboxes[1].checked).toBe(true) // Library 1
|
||||
expect(checkboxes[2].checked).toBe(false) // Library 2
|
||||
})
|
||||
|
||||
it('should handle value as array of objects', () => {
|
||||
|
|
@ -116,7 +162,101 @@ describe('<SelectLibraryInput />', () => {
|
|||
|
||||
// Check that checkbox for Library 2 is checked
|
||||
const checkboxes = screen.getAllByRole('checkbox')
|
||||
expect(checkboxes[0].checked).toBe(false)
|
||||
expect(checkboxes[1].checked).toBe(true)
|
||||
// With master checkbox, index shifts by 1
|
||||
expect(checkboxes[1].checked).toBe(false) // Library 1
|
||||
expect(checkboxes[2].checked).toBe(true) // Library 2
|
||||
})
|
||||
|
||||
it('should render master checkbox when there are multiple libraries', () => {
|
||||
// Mock libraries
|
||||
const mockLibraries = {
|
||||
1: { id: '1', name: 'Library 1' },
|
||||
2: { id: '2', name: 'Library 2' },
|
||||
}
|
||||
useGetList.mockReturnValue({
|
||||
ids: ['1', '2'],
|
||||
data: mockLibraries,
|
||||
})
|
||||
|
||||
render(<SelectLibraryInput onChange={mockOnChange} value={[]} />)
|
||||
|
||||
// Should render master checkbox plus individual checkboxes
|
||||
const checkboxes = screen.getAllByRole('checkbox')
|
||||
expect(checkboxes).toHaveLength(3) // 1 master + 2 individual
|
||||
expect(
|
||||
screen.getByText('resources.user.message.selectAllLibraries'),
|
||||
).not.toBeNull()
|
||||
})
|
||||
|
||||
it('should not render master checkbox when there is only one library', () => {
|
||||
// Mock single library
|
||||
const mockLibraries = {
|
||||
1: { id: '1', name: 'Library 1' },
|
||||
}
|
||||
useGetList.mockReturnValue({
|
||||
ids: ['1'],
|
||||
data: mockLibraries,
|
||||
})
|
||||
|
||||
render(<SelectLibraryInput onChange={mockOnChange} value={[]} />)
|
||||
|
||||
// Should render only individual checkbox
|
||||
const checkboxes = screen.getAllByRole('checkbox')
|
||||
expect(checkboxes).toHaveLength(1) // Only 1 individual checkbox
|
||||
})
|
||||
|
||||
it('should handle master checkbox selection and deselection', () => {
|
||||
// Mock libraries
|
||||
const mockLibraries = {
|
||||
1: { id: '1', name: 'Library 1' },
|
||||
2: { id: '2', name: 'Library 2' },
|
||||
}
|
||||
useGetList.mockReturnValue({
|
||||
ids: ['1', '2'],
|
||||
data: mockLibraries,
|
||||
})
|
||||
|
||||
render(<SelectLibraryInput onChange={mockOnChange} value={[]} />)
|
||||
|
||||
const checkboxes = screen.getAllByRole('checkbox')
|
||||
const masterCheckbox = checkboxes[0] // Master is first
|
||||
|
||||
// Click master checkbox to select all
|
||||
fireEvent.click(masterCheckbox)
|
||||
expect(mockOnChange).toHaveBeenCalledWith(['1', '2'])
|
||||
|
||||
// Clean up and test deselect all
|
||||
cleanup()
|
||||
mockOnChange.mockClear()
|
||||
|
||||
render(<SelectLibraryInput onChange={mockOnChange} value={['1', '2']} />)
|
||||
const checkboxes2 = screen.getAllByRole('checkbox')
|
||||
const masterCheckbox2 = checkboxes2[0]
|
||||
|
||||
// Click master checkbox to deselect all
|
||||
fireEvent.click(masterCheckbox2)
|
||||
expect(mockOnChange).toHaveBeenCalledWith([])
|
||||
})
|
||||
|
||||
it('should show master checkbox as indeterminate when some libraries are selected', () => {
|
||||
// Mock libraries
|
||||
const mockLibraries = {
|
||||
1: { id: '1', name: 'Library 1' },
|
||||
2: { id: '2', name: 'Library 2' },
|
||||
}
|
||||
useGetList.mockReturnValue({
|
||||
ids: ['1', '2'],
|
||||
data: mockLibraries,
|
||||
})
|
||||
|
||||
render(<SelectLibraryInput onChange={mockOnChange} value={['1']} />)
|
||||
|
||||
const checkboxes = screen.getAllByRole('checkbox')
|
||||
const masterCheckbox = checkboxes[0] // Master is first
|
||||
|
||||
// Master checkbox should not be checked when only some libraries are selected
|
||||
expect(masterCheckbox.checked).toBe(false)
|
||||
// Note: Testing indeterminate property directly through JSDOM can be unreliable
|
||||
// The important behavior is that it's not checked when only some are selected
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -166,9 +166,8 @@
|
|||
},
|
||||
"message": {
|
||||
"listenBrainzToken": "Enter your ListenBrainz user token.",
|
||||
"clickHereForToken": "Click here to get your token"
|
||||
},
|
||||
"messages": {
|
||||
"clickHereForToken": "Click here to get your token",
|
||||
"selectAllLibraries": "Select all libraries",
|
||||
"adminAutoLibraries": "Admin users automatically have access to all libraries"
|
||||
}
|
||||
},
|
||||
|
|
|
|||
|
|
@ -95,7 +95,7 @@ const UserCreate = (props) => {
|
|||
color="textSecondary"
|
||||
style={{ marginTop: 16, marginBottom: 16 }}
|
||||
>
|
||||
{translate('resources.user.messages.adminAutoLibraries')}
|
||||
{translate('resources.user.message.adminAutoLibraries')}
|
||||
</Typography>
|
||||
)}
|
||||
</>
|
||||
|
|
|
|||
|
|
@ -171,7 +171,7 @@ const UserEdit = (props) => {
|
|||
color="textSecondary"
|
||||
style={{ marginTop: 16, marginBottom: 16 }}
|
||||
>
|
||||
{translate('resources.user.messages.adminAutoLibraries')}
|
||||
{translate('resources.user.message.adminAutoLibraries')}
|
||||
</Typography>
|
||||
)}
|
||||
</>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue