From db167f56b9aeef9081a26c94b849999879b45d9a Mon Sep 17 00:00:00 2001 From: Deluan Date: Wed, 25 Jun 2025 19:20:21 -0400 Subject: [PATCH] feat(user): add library selection for user creation and editing Signed-off-by: Deluan --- ui/src/common/SelectLibraryInput.jsx | 141 +++++++++++++++++++++ ui/src/common/SelectLibraryInput.test.jsx | 122 ++++++++++++++++++ ui/src/dataProvider/wrapperDataProvider.js | 60 +++++++++ ui/src/i18n/en.json | 12 +- ui/src/user/LibrarySelectionField.jsx | 31 +++++ ui/src/user/LibrarySelectionField.test.jsx | 109 ++++++++++++++++ ui/src/user/UserCreate.jsx | 45 ++++++- ui/src/user/UserEdit.jsx | 40 ++++++ 8 files changed, 554 insertions(+), 6 deletions(-) create mode 100644 ui/src/common/SelectLibraryInput.jsx create mode 100644 ui/src/common/SelectLibraryInput.test.jsx create mode 100644 ui/src/user/LibrarySelectionField.jsx create mode 100644 ui/src/user/LibrarySelectionField.test.jsx diff --git a/ui/src/common/SelectLibraryInput.jsx b/ui/src/common/SelectLibraryInput.jsx new file mode 100644 index 000000000..4d34fd822 --- /dev/null +++ b/ui/src/common/SelectLibraryInput.jsx @@ -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 ( +
+ No libraries available +
+ ) +} + +const LibraryListItem = ({ library, isSelected, onToggle }) => { + const classes = useStyles() + + return ( + onToggle(library)} + dense + > + + } + checkedIcon={} + checked={isSelected} + tabIndex={-1} + disableRipple + /> + + + + ) +} + +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 ( +
+ + {options.length === 0 ? ( + + ) : ( + options.map((library) => ( + + )) + )} + +
+ ) +} + +SelectLibraryInput.propTypes = { + onChange: PropTypes.func.isRequired, + value: PropTypes.array, +} + +LibraryListItem.propTypes = { + library: PropTypes.object.isRequired, + isSelected: PropTypes.bool.isRequired, + onToggle: PropTypes.func.isRequired, +} diff --git a/ui/src/common/SelectLibraryInput.test.jsx b/ui/src/common/SelectLibraryInput.test.jsx new file mode 100644 index 000000000..706ece428 --- /dev/null +++ b/ui/src/common/SelectLibraryInput.test.jsx @@ -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('', () => { + 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() + 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() + 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() + + // 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() + + // 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() + + // 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() + + // 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) + }) +}) diff --git a/ui/src/dataProvider/wrapperDataProvider.js b/ui/src/dataProvider/wrapperDataProvider.js index 257a274e8..c0244f780 100644 --- a/ui/src/dataProvider/wrapperDataProvider.js +++ b/ui/src/dataProvider/wrapperDataProvider.js @@ -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) }, diff --git a/ui/src/i18n/en.json b/ui/src/i18n/en.json index 5693c11ed..3def7dcc5 100644 --- a/ui/src/i18n/en.json +++ b/ui/src/i18n/en.json @@ -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": { diff --git a/ui/src/user/LibrarySelectionField.jsx b/ui/src/user/LibrarySelectionField.jsx new file mode 100644 index 000000000..b18d04951 --- /dev/null +++ b/ui/src/user/LibrarySelectionField.jsx @@ -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 ( + + + {translate('resources.user.fields.libraries')} + + + + + {touched && error && ( + + {error} + + )} + + {translate('resources.user.helperTexts.libraries')} + + + ) +} diff --git a/ui/src/user/LibrarySelectionField.test.jsx b/ui/src/user/LibrarySelectionField.test.jsx new file mode 100644 index 000000000..849c8ba17 --- /dev/null +++ b/ui/src/user/LibrarySelectionField.test.jsx @@ -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(() =>
), +})) + +describe('', () => { + 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() + expect(screen.getByText('resources.user.fields.libraries')).not.toBeNull() + }) + + it('should render helper text from translations', () => { + render() + expect( + screen.getByText('resources.user.helperTexts.libraries'), + ).not.toBeNull() + }) + + it('should render SelectLibraryInput with correct props', () => { + render() + 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() + 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() + 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() + expect(SelectLibraryInput).toHaveBeenCalledWith( + expect.objectContaining({ + value: [], + }), + expect.anything(), + ) + }) +}) diff --git a/ui/src/user/UserCreate.jsx b/ui/src/user/UserCreate.jsx index 42ea1ce94..182f66ab5 100644 --- a/ui/src/user/UserCreate.jsx +++ b/ui/src/user/UserCreate.jsx @@ -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 ( } {...props}> - + { validate={[required()]} /> + + {/* Conditional Library Selection */} + + {({ formData }) => ( + <> + {!formData.isAdmin && } + + {formData.isAdmin && ( + + {translate('resources.user.messages.adminAutoLibraries')} + + )} + + )} + ) diff --git a/ui/src/user/UserEdit.jsx b/ui/src/user/UserEdit.jsx index 445f9c6fd..9401f8f7c 100644 --- a/ui/src/user/UserEdit.jsx +++ b/ui/src/user/UserEdit.jsx @@ -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 ( } undoable={false} {...props}> } save={save} + validate={validateUserForm} > {permissions === 'admin' && ( { {permissions === 'admin' && ( )} + + {/* Conditional Library Selection for Admin Users Only */} + {permissions === 'admin' && ( + + {({ formData }) => ( + <> + {!formData.isAdmin && } + + {formData.isAdmin && ( + + {translate('resources.user.messages.adminAutoLibraries')} + + )} + + )} + + )} +