diff --git a/ui/src/common/ReadOnlyFields.jsx b/ui/src/common/ReadOnlyFields.jsx new file mode 100644 index 000000000..c56643fbd --- /dev/null +++ b/ui/src/common/ReadOnlyFields.jsx @@ -0,0 +1,99 @@ +import React from 'react' +import PropTypes from 'prop-types' +import get from 'lodash/get' +import { FieldTitle, useRecordContext } from 'react-admin' +import { TextField } from '@material-ui/core' +import { makeStyles } from '@material-ui/core/styles' +import { useDateLocale } from '../i18n/useDateLocale' +import { + formatBytes, + formatDateTime, + formatDuration2, + formatNumber, +} from '../utils/formatters' +import { isDateSet } from '../utils/validations' + +const useStyles = makeStyles( + (theme) => ({ + inputRoot: { + '&:hover $notchedOutline': { + borderColor: theme.palette.divider, + }, + }, + notchedOutline: { + borderColor: theme.palette.divider, + }, + }), + { name: 'NDReadOnlyField' }, +) + +const identity = (v) => v + +// Renders a record value as a dimmed, non-editable input, so it lines up with the inputs in a form +export const ReadOnlyTextField = ({ + source, + label, + resource, + className, + fullWidth, + format = identity, + ...props +}) => { + const classes = useStyles(props) + const record = useRecordContext(props) + const value = get(record, source) + + return ( + } + value={value == null ? '' : format(value)} + variant="outlined" + margin="dense" + fullWidth={fullWidth} + focused={false} + helperText=" " + InputProps={{ + readOnly: true, + classes: { + root: classes.inputRoot, + notchedOutline: classes.notchedOutline, + }, + }} + inputProps={{ tabIndex: -1 }} + /> + ) +} + +ReadOnlyTextField.propTypes = { + source: PropTypes.string.isRequired, + label: PropTypes.oneOfType([PropTypes.string, PropTypes.bool]), + record: PropTypes.object, + resource: PropTypes.string, + className: PropTypes.string, + classes: PropTypes.object, + fullWidth: PropTypes.bool, + format: PropTypes.func, +} + +export const ReadOnlyDateField = (props) => { + const locale = useDateLocale() + const format = (v) => (isDateSet(v) ? formatDateTime(v, locale) : '') + return +} + +export const ReadOnlyNumberField = (props) => { + const locale = useDateLocale() + return ( + formatNumber(v, locale)} {...props} /> + ) +} + +export const ReadOnlySizeField = (props) => ( + +) + +export const ReadOnlyDurationField = (props) => ( + +) diff --git a/ui/src/common/ReadOnlyFields.test.jsx b/ui/src/common/ReadOnlyFields.test.jsx new file mode 100644 index 000000000..00543a786 --- /dev/null +++ b/ui/src/common/ReadOnlyFields.test.jsx @@ -0,0 +1,121 @@ +import React from 'react' +import { render, screen } from '@testing-library/react' +import { describe, it, expect, beforeEach, vi } from 'vitest' +import { + ReadOnlyDateField, + ReadOnlyDurationField, + ReadOnlyNumberField, + ReadOnlySizeField, + ReadOnlyTextField, +} from './ReadOnlyFields' + +vi.mock('react-admin', async (importOriginal) => ({ + ...(await importOriginal()), + useLocale: vi.fn(), +})) + +describe('ReadOnlyFields', () => { + const record = { + id: '1', + client: 'NavidromeUI', + createdAt: '2026-09-17T14:30:00Z', + lastVisitedAt: '0001-01-01T00:00:00Z', + count: 1234567, + zero: 0, + size: 1536000, + duration: 3725, + } + + beforeEach(async () => { + vi.clearAllMocks() + vi.spyOn(navigator, 'languages', 'get').mockReturnValue([]) + const { useLocale } = await import('react-admin') + vi.mocked(useLocale).mockReturnValue('de') + }) + + const renderField = (Field, props) => + render() + + describe('', () => { + it('shows the record value with the translated field label', () => { + renderField(ReadOnlyTextField, { source: 'client' }) + const input = screen.getByLabelText('resources.player.fields.client') + expect(input).toHaveValue('NavidromeUI') + }) + + it('cannot be edited or reached with the Tab key', () => { + renderField(ReadOnlyTextField, { source: 'client' }) + const input = screen.getByRole('textbox') + expect(input).toHaveAttribute('readonly') + expect(input).toHaveAttribute('tabindex', '-1') + }) + + it('shows an empty value when the record has no value', () => { + renderField(ReadOnlyTextField, { source: 'userName' }) + expect(screen.getByRole('textbox')).toHaveValue('') + }) + + it('uses an explicit label when given', () => { + renderField(ReadOnlyTextField, { source: 'client', label: 'Custom' }) + expect(screen.getByLabelText('Custom')).toBeInTheDocument() + }) + + it('applies a custom format', () => { + renderField(ReadOnlyTextField, { + source: 'client', + format: (v) => v.toUpperCase(), + }) + expect(screen.getByRole('textbox')).toHaveValue('NAVIDROMEUI') + }) + + it('exposes theme-overridable class names', () => { + const { container } = renderField(ReadOnlyTextField, { + source: 'client', + }) + expect( + container.querySelector('[class*="NDReadOnlyField-inputRoot"]'), + ).toBeInTheDocument() + expect( + container.querySelector('[class*="NDReadOnlyField-notchedOutline"]'), + ).toBeInTheDocument() + }) + }) + + describe('', () => { + it('formats the date using the selected language', () => { + renderField(ReadOnlyDateField, { source: 'createdAt' }) + expect(screen.getByRole('textbox').value).toMatch(/^17\.9\.2026, /) + }) + + it('shows an empty value when the date is not set', () => { + renderField(ReadOnlyDateField, { source: 'lastVisitedAt' }) + expect(screen.getByRole('textbox')).toHaveValue('') + }) + }) + + describe('', () => { + it('formats the number using the selected language', () => { + renderField(ReadOnlyNumberField, { source: 'count' }) + expect(screen.getByRole('textbox')).toHaveValue('1.234.567') + }) + + it('shows zero', () => { + renderField(ReadOnlyNumberField, { source: 'zero' }) + expect(screen.getByRole('textbox')).toHaveValue('0') + }) + }) + + describe('', () => { + it('formats bytes as a human-readable size', () => { + renderField(ReadOnlySizeField, { source: 'size' }) + expect(screen.getByRole('textbox')).toHaveValue('1.46 MB') + }) + }) + + describe('', () => { + it('formats seconds as a human-readable duration', () => { + renderField(ReadOnlyDurationField, { source: 'duration' }) + expect(screen.getByRole('textbox')).toHaveValue('1h 2m 5s') + }) + }) +}) diff --git a/ui/src/common/index.js b/ui/src/common/index.js index 0177df326..fb8f40f00 100644 --- a/ui/src/common/index.js +++ b/ui/src/common/index.js @@ -15,6 +15,7 @@ export * from './perPageStore' export * from './PlayButton' export * from './QuickFilter' export * from './RangeField' +export * from './ReadOnlyFields' export * from './ShuffleAllButton' export * from './SimpleList' export * from './SizeField' diff --git a/ui/src/library/LibraryEdit.jsx b/ui/src/library/LibraryEdit.jsx index 7e89c892c..53d17ac7f 100644 --- a/ui/src/library/LibraryEdit.jsx +++ b/ui/src/library/LibraryEdit.jsx @@ -6,7 +6,6 @@ import { BooleanInput, required, SaveButton, - DateField, useTranslate, useMutation, useNotify, @@ -16,8 +15,13 @@ import { import { Typography, Box } from '@material-ui/core' import { makeStyles } from '@material-ui/core/styles' import DeleteLibraryButton from './DeleteLibraryButton' -import { Title } from '../common' -import { formatBytes, formatDuration2, formatNumber } from '../utils/index.js' +import { + ReadOnlyDateField, + ReadOnlyDurationField, + ReadOnlyNumberField, + ReadOnlySizeField, + Title, +} from '../common' const useStyles = makeStyles({ toolbar: { @@ -26,6 +30,8 @@ const useStyles = makeStyles({ }, }) +const readOnlyProps = { resource: 'library', fullWidth: true } + const LibraryTitle = ({ record }) => { const translate = useTranslate() const resourceName = translate('resources.library.name', { smart_count: 1 }) @@ -125,132 +131,40 @@ const LibraryEdit = (props) => { {translate('resources.library.sections.statistics')} - - - - - - - - - - - - - - - formatBytes(v, 2)} - fullWidth - variant="outlined" - /> - - - - - - - - - - - - - {/* Timestamps Section */} - - - {translate('resources.library.fields.lastScanAt')} - - + - - - - - {translate('resources.library.fields.updatedAt')} - - - - - - - {translate('resources.library.fields.createdAt')} - - + + + + + + + + diff --git a/ui/src/player/PlayerEdit.jsx b/ui/src/player/PlayerEdit.jsx index 08f389698..d785eb04e 100644 --- a/ui/src/player/PlayerEdit.jsx +++ b/ui/src/player/PlayerEdit.jsx @@ -1,5 +1,4 @@ import { - TextField, Edit, SimpleForm, useTranslate, @@ -9,7 +8,7 @@ import { Toolbar, } from 'react-admin' import { makeStyles } from '@material-ui/core/styles' -import { Title } from '../common' +import { ReadOnlyTextField, Title } from '../common' import ApiKeyInput from './ApiKeyInput' import { playerInputs } from './playerInputs' @@ -45,8 +44,8 @@ const PlayerEdit = (props) => ( } mutationMode="pessimistic" {...props}> }> {playerInputs()} - - + + diff --git a/ui/src/playlist/PlaylistEdit.jsx b/ui/src/playlist/PlaylistEdit.jsx index f6882e366..c7594fa13 100644 --- a/ui/src/playlist/PlaylistEdit.jsx +++ b/ui/src/playlist/PlaylistEdit.jsx @@ -3,7 +3,6 @@ import { FormDataConsumer, SimpleForm, TextInput, - TextField, BooleanInput, required, useTranslate, @@ -11,13 +10,14 @@ import { ReferenceInput, SelectInput, } from 'react-admin' -import { isWritable, Title } from '../common' +import { isWritable, ReadOnlyTextField, Title } from '../common' const SyncFragment = ({ formData, variant, ...rest }) => { + if (!formData.path) return null return ( <> - {formData.path && } - {formData.path && } + + ) } @@ -56,10 +56,10 @@ const PlaylistEditForm = (props) => { /> ) : ( - + )} - + {(formDataProps) => } diff --git a/ui/src/radio/RadioEdit.jsx b/ui/src/radio/RadioEdit.jsx index bbe001e6f..af879deaa 100644 --- a/ui/src/radio/RadioEdit.jsx +++ b/ui/src/radio/RadioEdit.jsx @@ -1,5 +1,4 @@ import { - DateField, Edit, required, SimpleForm, @@ -9,7 +8,12 @@ import { import { CardMedia } from '@material-ui/core' import { makeStyles } from '@material-ui/core/styles' import { urlValidate } from '../utils/validations' -import { Title, ImageUploadOverlay, useImageLoadingState } from '../common' +import { + Title, + ImageUploadOverlay, + ReadOnlyDateField, + useImageLoadingState, +} from '../common' import subsonic from '../subsonic' import config from '../config' import { RADIO_PLACEHOLDER_IMAGE } from '../consts' @@ -65,8 +69,8 @@ const RadioEdit = (props) => { fullWidth validate={[urlValidate]} /> - - + + ) diff --git a/ui/src/share/ShareEdit.jsx b/ui/src/share/ShareEdit.jsx index 2cf7f2df7..a222d3369 100644 --- a/ui/src/share/ShareEdit.jsx +++ b/ui/src/share/ShareEdit.jsx @@ -2,13 +2,16 @@ import { DateTimeInput, BooleanInput, Edit, - NumberField, SimpleForm, TextInput, } from 'react-admin' import { sharePlayerUrl } from '../utils' import { Link } from '@material-ui/core' -import { DateField } from '../common' +import { + ReadOnlyDateField, + ReadOnlyNumberField, + ReadOnlyTextField, +} from '../common' import config from '../config' export const ShareEdit = (props) => { @@ -16,20 +19,26 @@ export const ShareEdit = (props) => { const url = sharePlayerUrl(id) return ( - - + + {url} {config.enableDownloads && } - - - - - - - + + + + + + + ) diff --git a/ui/src/user/UserEdit.jsx b/ui/src/user/UserEdit.jsx index c5d9c75a4..feadafff1 100644 --- a/ui/src/user/UserEdit.jsx +++ b/ui/src/user/UserEdit.jsx @@ -3,7 +3,6 @@ import { makeStyles } from '@material-ui/core/styles' import { TextInput, BooleanInput, - DateField, PasswordInput, Edit, required, @@ -21,7 +20,7 @@ import { useRecordContext, } from 'react-admin' import { Typography } from '@material-ui/core' -import { Title } from '../common' +import { ReadOnlyDateField, Title } from '../common' import DeleteUserButton from './DeleteUserButton' import { LibrarySelectionField } from './LibrarySelectionField.jsx' import { validateUserForm } from './userValidation' @@ -183,10 +182,10 @@ const UserEdit = (props) => { helperText={translate('resources.user.helperTexts.scrobbleFilter')} /> - - - - + + + + ) diff --git a/ui/src/user/UserEdit.test.jsx b/ui/src/user/UserEdit.test.jsx index 74405cc13..837b25ad6 100644 --- a/ui/src/user/UserEdit.test.jsx +++ b/ui/src/user/UserEdit.test.jsx @@ -51,9 +51,6 @@ vi.mock('react-admin', () => ({ BooleanInput: ({ source }) => ( ), - DateField: ({ source }) => ( -
Date
- ), PasswordInput: ({ source }) => ( ), @@ -82,6 +79,9 @@ vi.mock('./DeleteUserButton', () => ({ vi.mock('../common', () => ({ Title: ({ subTitle }) =>
{subTitle}
, + ReadOnlyDateField: ({ source }) => ( +
Date
+ ), })) // Mock Material-UI