feat(user): add library selection for user creation and editing

Signed-off-by: Deluan <deluan@navidrome.org>
This commit is contained in:
Deluan 2025-06-25 19:20:21 -04:00
commit db167f56b9
8 changed files with 554 additions and 6 deletions

View file

@ -0,0 +1,141 @@
import React, { useState, useEffect } from 'react'
import Checkbox from '@material-ui/core/Checkbox'
import CheckBoxIcon from '@material-ui/icons/CheckBox'
import CheckBoxOutlineBlankIcon from '@material-ui/icons/CheckBoxOutlineBlank'
import {
List,
ListItem,
ListItemIcon,
ListItemText,
Typography,
} from '@material-ui/core'
import { useGetList } from 'react-admin'
import PropTypes from 'prop-types'
import { makeStyles } from '@material-ui/core'
const useStyles = makeStyles((theme) => ({
root: {
width: '960px', // MD breakpoint width
maxWidth: '100%',
},
libraryList: {
height: '120px', // 3 rows of libraries
overflow: 'auto',
border: `1px solid ${theme.palette.divider}`,
borderRadius: theme.shape.borderRadius,
backgroundColor: theme.palette.background.paper,
},
listItem: {
paddingTop: 0,
paddingBottom: 0,
},
emptyMessage: {
padding: theme.spacing(2),
textAlign: 'center',
color: theme.palette.text.secondary,
},
}))
const EmptyLibraryMessage = () => {
const classes = useStyles()
return (
<div className={classes.emptyMessage}>
<Typography variant="body2">No libraries available</Typography>
</div>
)
}
const LibraryListItem = ({ library, isSelected, onToggle }) => {
const classes = useStyles()
return (
<ListItem
className={classes.listItem}
button
onClick={() => onToggle(library)}
dense
>
<ListItemIcon>
<Checkbox
icon={<CheckBoxOutlineBlankIcon fontSize="small" />}
checkedIcon={<CheckBoxIcon fontSize="small" />}
checked={isSelected}
tabIndex={-1}
disableRipple
/>
</ListItemIcon>
<ListItemText primary={library.name} />
</ListItem>
)
}
export const SelectLibraryInput = ({ onChange, value = [] }) => {
const classes = useStyles()
const [selectedLibraryIds, setSelectedLibraryIds] = useState([])
const { ids, data } = useGetList(
'library',
{ page: 1, perPage: -1 },
{ field: 'name', order: 'ASC' },
{ smart: false },
)
const options = (ids && ids.map((id) => data[id])) || []
// Update selectedLibraryIds when value prop changes (for editing mode)
useEffect(() => {
if (Array.isArray(value)) {
const libraryIds = value.map((item) =>
typeof item === 'object' ? item.id : item,
)
setSelectedLibraryIds(libraryIds)
}
}, [value])
const isLibrarySelected = (library) => selectedLibraryIds.includes(library.id)
const handleLibraryToggle = (library) => {
const isSelected = selectedLibraryIds.includes(library.id)
let newSelection
if (isSelected) {
newSelection = selectedLibraryIds.filter((id) => id !== library.id)
} else {
newSelection = [...selectedLibraryIds, library.id]
}
setSelectedLibraryIds(newSelection)
onChange(newSelection)
}
return (
<div className={classes.root}>
<List className={classes.libraryList}>
{options.length === 0 ? (
<EmptyLibraryMessage />
) : (
options.map((library) => (
<LibraryListItem
key={library.id}
library={library}
isSelected={isLibrarySelected(library)}
onToggle={handleLibraryToggle}
/>
))
)}
</List>
</div>
)
}
SelectLibraryInput.propTypes = {
onChange: PropTypes.func.isRequired,
value: PropTypes.array,
}
LibraryListItem.propTypes = {
library: PropTypes.object.isRequired,
isSelected: PropTypes.bool.isRequired,
onToggle: PropTypes.func.isRequired,
}

View file

@ -0,0 +1,122 @@
import * as React from 'react'
import { render, screen, fireEvent, cleanup } from '@testing-library/react'
import { SelectLibraryInput } from './SelectLibraryInput'
import { useGetList } from 'react-admin'
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
// Mock the react-admin hook
vi.mock('react-admin', () => ({
useGetList: vi.fn(),
}))
describe('<SelectLibraryInput />', () => {
const mockOnChange = vi.fn()
beforeEach(() => {
// Reset the mock before each test
mockOnChange.mockClear()
})
afterEach(cleanup)
it('should render empty message when no libraries available', () => {
// Mock empty library response
useGetList.mockReturnValue({
ids: [],
data: {},
})
render(<SelectLibraryInput onChange={mockOnChange} value={[]} />)
expect(screen.getByText('No libraries available')).not.toBeNull()
})
it('should render libraries when available', () => {
// 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={[]} />)
expect(screen.getByText('Library 1')).not.toBeNull()
expect(screen.getByText('Library 2')).not.toBeNull()
})
it('should toggle selection when a library is clicked', () => {
// Mock libraries
const mockLibraries = {
1: { id: '1', name: 'Library 1' },
2: { id: '2', name: 'Library 2' },
}
// Test selecting an item
useGetList.mockReturnValue({
ids: ['1', '2'],
data: mockLibraries,
})
render(<SelectLibraryInput onChange={mockOnChange} value={[]} />)
// First test - click to select
fireEvent.click(screen.getAllByRole('button')[0]) // Click first list item
expect(mockOnChange).toHaveBeenCalledWith(['1'])
// Clean up to avoid DOM duplication
cleanup()
mockOnChange.mockClear()
// Test deselecting an item
useGetList.mockReturnValue({
ids: ['1', '2'],
data: mockLibraries,
})
render(<SelectLibraryInput onChange={mockOnChange} value={['1']} />)
// Second test - click to deselect
fireEvent.click(screen.getAllByRole('button')[0]) // Click first list item again
expect(mockOnChange).toHaveBeenCalledWith([])
})
it('should correctly initialize with provided values', () => {
// Mock libraries
const mockLibraries = {
1: { id: '1', name: 'Library 1' },
2: { id: '2', name: 'Library 2' },
}
useGetList.mockReturnValue({
ids: ['1', '2'],
data: mockLibraries,
})
// Initial value as array of IDs
render(<SelectLibraryInput onChange={mockOnChange} value={['1']} />)
// 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)
})
it('should handle value as array of objects', () => {
// Mock libraries
const mockLibraries = {
1: { id: '1', name: 'Library 1' },
2: { id: '2', name: 'Library 2' },
}
useGetList.mockReturnValue({
ids: ['1', '2'],
data: mockLibraries,
})
// Initial value as array of objects with id property
render(<SelectLibraryInput onChange={mockOnChange} value={[{ id: '2' }]} />)
// 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)
})
})

View file

@ -43,6 +43,60 @@ const callDeleteMany = (resource, params) => {
}).then((response) => ({ data: response.json.ids || [] }))
}
// Helper function to handle user-library associations
const handleUserLibraryAssociation = async (userId, libraryIds) => {
if (!libraryIds || libraryIds.length === 0) {
return // Admin users or users without library assignments
}
try {
await httpClient(`${REST_URL}/user/${userId}/library`, {
method: 'PUT',
body: JSON.stringify({ libraryIds }),
})
} catch (error) {
console.error('Error setting user libraries:', error) //eslint-disable-line no-console
throw error
}
}
// Enhanced user creation that handles library associations
const createUser = async (params) => {
const { data } = params
const { libraryIds, ...userData } = data
// First create the user
const userResponse = await dataProvider.create('user', { data: userData })
const userId = userResponse.data.id
// Then set library associations for non-admin users
if (!userData.isAdmin && libraryIds && libraryIds.length > 0) {
await handleUserLibraryAssociation(userId, libraryIds)
}
return userResponse
}
// Enhanced user update that handles library associations
const updateUser = async (params) => {
const { data } = params
const { libraryIds, ...userData } = data
const userId = params.id
// First update the user
const userResponse = await dataProvider.update('user', {
...params,
data: userData,
})
// Then handle library associations for non-admin users
if (!userData.isAdmin && libraryIds !== undefined) {
await handleUserLibraryAssociation(userId, libraryIds)
}
return userResponse
}
const wrapperDataProvider = {
...dataProvider,
getList: (resource, params) => {
@ -62,6 +116,9 @@ const wrapperDataProvider = {
return dataProvider.getManyReference(r, p)
},
update: (resource, params) => {
if (resource === 'user') {
return updateUser(params)
}
const [r, p] = mapResource(resource, params)
return dataProvider.update(r, p)
},
@ -70,6 +127,9 @@ const wrapperDataProvider = {
return dataProvider.updateMany(r, p)
},
create: (resource, params) => {
if (resource === 'user') {
return createUser(params)
}
const [r, p] = mapResource(resource, params)
return dataProvider.create(r, p)
},

View file

@ -149,19 +149,27 @@
"changePassword": "Change Password?",
"currentPassword": "Current Password",
"newPassword": "New Password",
"token": "Token"
"token": "Token",
"libraries": "Libraries"
},
"helperTexts": {
"name": "Changes to your name will only be reflected on next login"
"name": "Changes to your name will only be reflected on next login",
"libraries": "Select which libraries this user can access"
},
"notifications": {
"created": "User created",
"updated": "User updated",
"deleted": "User deleted"
},
"validation": {
"librariesRequired": "At least one library must be selected for non-admin users"
},
"message": {
"listenBrainzToken": "Enter your ListenBrainz user token.",
"clickHereForToken": "Click here to get your token"
},
"messages": {
"adminAutoLibraries": "Admin users automatically have access to all libraries"
}
},
"player": {

View file

@ -0,0 +1,31 @@
import { useInput, useTranslate } from 'react-admin'
import { Box, FormControl, FormLabel, Typography } from '@material-ui/core'
import { SelectLibraryInput } from '../common/SelectLibraryInput.jsx'
import React from 'react'
export const LibrarySelectionField = () => {
const translate = useTranslate()
const {
input: { name, onChange, value },
meta: { error, touched },
} = useInput({ source: 'libraryIds' })
return (
<FormControl error={!!(touched && error)} fullWidth margin="normal">
<FormLabel component="legend" required>
{translate('resources.user.fields.libraries')}
</FormLabel>
<Box mt={1} mb={1}>
<SelectLibraryInput onChange={onChange} value={value || []} />
</Box>
{touched && error && (
<Typography color="error" variant="caption">
{error}
</Typography>
)}
<Typography variant="caption" color="textSecondary">
{translate('resources.user.helperTexts.libraries')}
</Typography>
</FormControl>
)
}

View file

@ -0,0 +1,109 @@
import * as React from 'react'
import { render, screen, cleanup } from '@testing-library/react'
import { LibrarySelectionField } from './LibrarySelectionField'
import { useInput, useTranslate } from 'react-admin'
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
import { SelectLibraryInput } from '../common/SelectLibraryInput'
// Mock the react-admin hooks
vi.mock('react-admin', () => ({
useInput: vi.fn(),
useTranslate: vi.fn(),
}))
// Mock the SelectLibraryInput component
vi.mock('../common/SelectLibraryInput.jsx', () => ({
SelectLibraryInput: vi.fn(() => <div data-testid="select-library-input" />),
}))
describe('<LibrarySelectionField />', () => {
const defaultProps = {
input: {
name: 'libraryIds',
value: [],
onChange: vi.fn(),
},
meta: {
touched: false,
error: undefined,
},
}
const mockTranslate = vi.fn((key) => key)
beforeEach(() => {
useInput.mockReturnValue(defaultProps)
useTranslate.mockReturnValue(mockTranslate)
SelectLibraryInput.mockClear()
})
afterEach(cleanup)
it('should render field label from translations', () => {
render(<LibrarySelectionField />)
expect(screen.getByText('resources.user.fields.libraries')).not.toBeNull()
})
it('should render helper text from translations', () => {
render(<LibrarySelectionField />)
expect(
screen.getByText('resources.user.helperTexts.libraries'),
).not.toBeNull()
})
it('should render SelectLibraryInput with correct props', () => {
render(<LibrarySelectionField />)
expect(screen.getByTestId('select-library-input')).not.toBeNull()
expect(SelectLibraryInput).toHaveBeenCalledWith(
expect.objectContaining({
onChange: defaultProps.input.onChange,
value: defaultProps.input.value,
}),
expect.anything(),
)
})
it('should render error message when touched and has error', () => {
useInput.mockReturnValue({
...defaultProps,
meta: {
touched: true,
error: 'This field is required',
},
})
render(<LibrarySelectionField />)
expect(screen.getByText('This field is required')).not.toBeNull()
})
it('should not render error message when not touched', () => {
useInput.mockReturnValue({
...defaultProps,
meta: {
touched: false,
error: 'This field is required',
},
})
render(<LibrarySelectionField />)
expect(screen.queryByText('This field is required')).toBeNull()
})
it('should initialize with empty array when value is null', () => {
useInput.mockReturnValue({
...defaultProps,
input: {
...defaultProps.input,
value: null,
},
})
render(<LibrarySelectionField />)
expect(SelectLibraryInput).toHaveBeenCalledWith(
expect.objectContaining({
value: [],
}),
expect.anything(),
)
})
})

View file

@ -2,17 +2,20 @@ import React, { useCallback } from 'react'
import {
BooleanInput,
Create,
TextInput,
email,
FormDataConsumer,
PasswordInput,
required,
email,
SimpleForm,
useTranslate,
TextInput,
useMutation,
useNotify,
useRedirect,
useTranslate,
} from 'react-admin'
import { Typography } from '@material-ui/core'
import { Title } from '../common'
import { LibrarySelectionField } from './LibrarySelectionField.jsx'
const UserCreate = (props) => {
const translate = useTranslate()
@ -48,9 +51,24 @@ const UserCreate = (props) => {
[mutate, notify, redirect],
)
// Custom validation function
const validateUserForm = (values) => {
const errors = {}
// Only require library selection for non-admin users
if (
!values.isAdmin &&
(!values.libraryIds || values.libraryIds.length === 0)
) {
errors.libraryIds = translate(
'resources.user.validation.librariesRequired',
)
}
return errors
}
return (
<Create title={<Title subTitle={title} />} {...props}>
<SimpleForm save={save} variant={'outlined'}>
<SimpleForm save={save} validate={validateUserForm} variant={'outlined'}>
<TextInput
spellCheck={false}
source="userName"
@ -64,6 +82,25 @@ const UserCreate = (props) => {
validate={[required()]}
/>
<BooleanInput source="isAdmin" defaultValue={false} />
{/* Conditional Library Selection */}
<FormDataConsumer>
{({ formData }) => (
<>
{!formData.isAdmin && <LibrarySelectionField />}
{formData.isAdmin && (
<Typography
variant="body2"
color="textSecondary"
style={{ marginTop: 16, marginBottom: 16 }}
>
{translate('resources.user.messages.adminAutoLibraries')}
</Typography>
)}
</>
)}
</FormDataConsumer>
</SimpleForm>
</Create>
)

View file

@ -19,8 +19,10 @@ import {
FormDataConsumer,
usePermissions,
} from 'react-admin'
import { Typography } from '@material-ui/core'
import { Title } from '../common'
import DeleteUserButton from './DeleteUserButton'
import { LibrarySelectionField } from './LibrarySelectionField.jsx'
const useStyles = makeStyles({
toolbar: {
@ -100,12 +102,28 @@ const UserEdit = (props) => {
[mutate, notify, permissions, redirect, refresh],
)
// Custom validation function
const validateUserForm = (values) => {
const errors = {}
// Only require library selection for non-admin users
if (
!values.isAdmin &&
(!values.libraryIds || values.libraryIds.length === 0)
) {
errors.libraryIds = translate(
'resources.user.validation.librariesRequired',
)
}
return errors
}
return (
<Edit title={<UserTitle />} undoable={false} {...props}>
<SimpleForm
variant={'outlined'}
toolbar={<UserToolbar showDelete={canDelete} />}
save={save}
validate={validateUserForm}
>
{permissions === 'admin' && (
<TextInput
@ -139,6 +157,28 @@ const UserEdit = (props) => {
{permissions === 'admin' && (
<BooleanInput source="isAdmin" initialValue={false} />
)}
{/* Conditional Library Selection for Admin Users Only */}
{permissions === 'admin' && (
<FormDataConsumer>
{({ formData }) => (
<>
{!formData.isAdmin && <LibrarySelectionField />}
{formData.isAdmin && (
<Typography
variant="body2"
color="textSecondary"
style={{ marginTop: 16, marginBottom: 16 }}
>
{translate('resources.user.messages.adminAutoLibraries')}
</Typography>
)}
</>
)}
</FormDataConsumer>
)}
<DateField variant="body1" source="lastLoginAt" showTime />
<DateField variant="body1" source="lastAccessAt" showTime />
<DateField variant="body1" source="updatedAt" showTime />