feat(ui): show read-only values in edit forms as dimmed, themable inputs (#6238)

This commit is contained in:
Deluan Quintão 2026-09-28 08:48:36 -04:00 • committed by GitHub
commit 612c8290f9
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 306 additions and 160 deletions

View file

@ -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 (
<TextField
id={source}
className={className}
label={<FieldTitle label={label} source={source} resource={resource} />}
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 <ReadOnlyTextField format={format} {...props} />
}
export const ReadOnlyNumberField = (props) => {
const locale = useDateLocale()
return (
<ReadOnlyTextField format={(v) => formatNumber(v, locale)} {...props} />
)
}
export const ReadOnlySizeField = (props) => (
<ReadOnlyTextField format={formatBytes} {...props} />
)
export const ReadOnlyDurationField = (props) => (
<ReadOnlyTextField format={formatDuration2} {...props} />
)

View file

@ -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(<Field record={record} resource="player" {...props} />)
describe('<ReadOnlyTextField>', () => {
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('<ReadOnlyDateField>', () => {
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('<ReadOnlyNumberField>', () => {
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('<ReadOnlySizeField>', () => {
it('formats bytes as a human-readable size', () => {
renderField(ReadOnlySizeField, { source: 'size' })
expect(screen.getByRole('textbox')).toHaveValue('1.46 MB')
})
})
describe('<ReadOnlyDurationField>', () => {
it('formats seconds as a human-readable duration', () => {
renderField(ReadOnlyDurationField, { source: 'duration' })
expect(screen.getByRole('textbox')).toHaveValue('1h 2m 5s')
})
})
})

View file

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

View file

@ -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')}
</Typography>
<Box display="flex">
<Box flex={1} mr="0.5em">
<TextInput
InputProps={{ readOnly: true }}
resource={'library'}
source={'totalSongs'}
label={translate('resources.library.fields.totalSongs')}
fullWidth
variant="outlined"
/>
</Box>
<Box flex={1} ml="0.5em">
<TextInput
InputProps={{ readOnly: true }}
resource={'library'}
source={'totalAlbums'}
label={translate(
'resources.library.fields.totalAlbums',
)}
fullWidth
variant="outlined"
/>
</Box>
</Box>
<Box display="flex">
<Box flex={1} mr="0.5em">
<TextInput
InputProps={{ readOnly: true }}
resource={'library'}
source={'totalArtists'}
label={translate(
'resources.library.fields.totalArtists',
)}
fullWidth
variant="outlined"
/>
</Box>
<Box flex={1} ml="0.5em">
<TextInput
InputProps={{ readOnly: true }}
resource={'library'}
source={'totalSize'}
label={translate('resources.library.fields.totalSize')}
format={(v) => formatBytes(v, 2)}
fullWidth
variant="outlined"
/>
</Box>
</Box>
<Box display="flex">
<Box flex={1} mr="0.5em">
<TextInput
InputProps={{ readOnly: true }}
resource={'library'}
source={'totalDuration'}
label={translate(
'resources.library.fields.totalDuration',
)}
format={formatDuration2}
fullWidth
variant="outlined"
/>
</Box>
<Box flex={1} ml="0.5em">
<TextInput
InputProps={{ readOnly: true }}
resource={'library'}
source={'totalMissingFiles'}
label={translate(
'resources.library.fields.totalMissingFiles',
)}
fullWidth
variant="outlined"
/>
</Box>
</Box>
{/* Timestamps Section */}
<Box mb="1em">
<Typography
variant="body2"
color="textSecondary"
gutterBottom
>
{translate('resources.library.fields.lastScanAt')}
</Typography>
<DateField
variant="body1"
source="lastScanAt"
showTime
record={formProps.record}
<Box
display="grid"
gridTemplateColumns="1fr 1fr"
gridColumnGap="1em"
>
<ReadOnlyNumberField
source="totalSongs"
{...readOnlyProps}
/>
</Box>
<Box mb="1em">
<Typography
variant="body2"
color="textSecondary"
gutterBottom
>
{translate('resources.library.fields.updatedAt')}
</Typography>
<DateField
variant="body1"
source="updatedAt"
showTime
record={formProps.record}
<ReadOnlyNumberField
source="totalAlbums"
{...readOnlyProps}
/>
</Box>
<Box mb="2em">
<Typography
variant="body2"
color="textSecondary"
gutterBottom
>
{translate('resources.library.fields.createdAt')}
</Typography>
<DateField
variant="body1"
source="createdAt"
showTime
record={formProps.record}
<ReadOnlyNumberField
source="totalArtists"
{...readOnlyProps}
/>
<ReadOnlySizeField source="totalSize" {...readOnlyProps} />
<ReadOnlyDurationField
source="totalDuration"
{...readOnlyProps}
/>
<ReadOnlyNumberField
source="totalMissingFiles"
{...readOnlyProps}
/>
<Box gridColumn="1 / -1">
<ReadOnlyDateField
source="lastScanAt"
{...readOnlyProps}
/>
</Box>
<ReadOnlyDateField source="updatedAt" {...readOnlyProps} />
<ReadOnlyDateField source="createdAt" {...readOnlyProps} />
</Box>
</Box>
</Box>

View file

@ -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) => (
<Edit title={<PlayerTitle />} mutationMode="pessimistic" {...props}>
<SimpleForm variant={'outlined'} toolbar={<PlayerEditToolbar />}>
{playerInputs()}
<TextField source="client" />
<TextField source="userName" />
<ReadOnlyTextField source="client" />
<ReadOnlyTextField source="userName" />
<ApiKeyInput source="apiKey" />
</SimpleForm>
</Edit>

View file

@ -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 && <BooleanInput source="sync" {...rest} />}
{formData.path && <TextField source="path" {...rest} />}
<BooleanInput source="sync" {...rest} />
<ReadOnlyTextField source="path" {...rest} />
</>
)
}
@ -56,10 +56,10 @@ const PlaylistEditForm = (props) => {
/>
</ReferenceInput>
) : (
<TextField source="ownerName" />
<ReadOnlyTextField source="ownerName" />
)}
<BooleanInput source="public" disabled={!isWritable(record.ownerId)} />
<FormDataConsumer>
<FormDataConsumer fullWidth>
{(formDataProps) => <SyncFragment {...formDataProps} />}
</FormDataConsumer>
</SimpleForm>

View file

@ -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]}
/>
<DateField variant="body1" source="updatedAt" showTime />
<DateField variant="body1" source="createdAt" showTime />
<ReadOnlyDateField source="updatedAt" />
<ReadOnlyDateField source="createdAt" />
</SimpleForm>
</Edit>
)

View file

@ -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 (
<Edit {...props}>
<SimpleForm {...rest}>
<Link source="URL" href={url} target="_blank" rel="noopener noreferrer">
<SimpleForm variant={'outlined'} {...rest}>
<Link
source="URL"
href={url}
target="_blank"
rel="noopener noreferrer"
variant="inherit"
>
{url}
</Link>
<TextInput source="description" />
{config.enableDownloads && <BooleanInput source="downloadable" />}
<DateTimeInput source="expiresAt" />
<TextInput source="contents" disabled />
<TextInput source="format" disabled />
<TextInput source="maxBitRate" disabled />
<TextInput source="username" disabled />
<NumberField source="visitCount" disabled />
<DateField source="lastVisitedAt" disabled showTime />
<DateField source="createdAt" disabled showTime />
<ReadOnlyTextField source="contents" />
<ReadOnlyTextField source="format" />
<ReadOnlyTextField source="maxBitRate" />
<ReadOnlyTextField source="username" />
<ReadOnlyNumberField source="visitCount" />
<ReadOnlyDateField source="lastVisitedAt" />
<ReadOnlyDateField source="createdAt" />
</SimpleForm>
</Edit>
)

View file

@ -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')}
/>
<DateField variant="body1" source="lastLoginAt" showTime />
<DateField variant="body1" source="lastAccessAt" showTime />
<DateField variant="body1" source="updatedAt" showTime />
<DateField variant="body1" source="createdAt" showTime />
<ReadOnlyDateField source="lastLoginAt" />
<ReadOnlyDateField source="lastAccessAt" />
<ReadOnlyDateField source="updatedAt" />
<ReadOnlyDateField source="createdAt" />
</SimpleForm>
</Edit>
)

View file

@ -51,9 +51,6 @@ vi.mock('react-admin', () => ({
BooleanInput: ({ source }) => (
<input type="checkbox" data-testid={`boolean-input-${source}`} />
),
DateField: ({ source }) => (
<div data-testid={`date-field-${source}`}>Date</div>
),
PasswordInput: ({ source }) => (
<input type="password" data-testid={`password-input-${source}`} />
),
@ -82,6 +79,9 @@ vi.mock('./DeleteUserButton', () => ({
vi.mock('../common', () => ({
Title: ({ subTitle }) => <div data-testid="title">{subTitle}</div>,
ReadOnlyDateField: ({ source }) => (
<div data-testid={`date-field-${source}`}>Date</div>
),
}))
// Mock Material-UI