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:
Deluan 2025-06-29 17:01:57 -04:00
commit 0b2d728b21
5 changed files with 193 additions and 14 deletions

View file

@ -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 />

View file

@ -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
})
})

View file

@ -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"
}
},

View file

@ -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>
)}
</>

View file

@ -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>
)}
</>