feat: add Rated At field - #4653 (#4660)

* feat(model): add Rated At field - #4653

Signed-off-by: zacaj <zacaj@zacaj.com>

* fix(ui): ignore empty dates in rating/love tooltips - #4653

* refactor(ui): add isDateSet util function

Signed-off-by: zacaj <zacaj@zacaj.com>

* feat: add tests for isDateSet and rated_at sort mappings

Added comprehensive tests for isDateSet and urlValidate functions in
ui/src/utils/validations.test.js covering falsy values, Go zero date handling,
valid date strings, Date objects, and edge cases.

Added rated_at sort mapping to album, artist, and mediafile repositories,
following the same pattern as starred_at (sorting by rating first, then by
timestamp). This enables proper sorting by rating date in the UI.

---------

Signed-off-by: zacaj <zacaj@zacaj.com>
Co-authored-by: zacaj <zacaj@zacaj.com>
Co-authored-by: Deluan <deluan@navidrome.org>
This commit is contained in:
zacaj
2025-11-24 23:18:05 -05:00
committed by GitHub
parent 228211f925
commit 3294bcacfc
14 changed files with 121 additions and 4 deletions
+2 -1
View File
@@ -1,10 +1,11 @@
import React from 'react'
import { isDateSet } from '../utils/validations'
import { DateField as RADateField } from 'react-admin'
export const DateField = (props) => {
const { record, source } = props
const value = record?.[source]
if (value === '0001-01-01T00:00:00Z' || value === null) return null
if (!isDateSet(value)) return null
return <RADateField {...props} />
}
+7 -1
View File
@@ -7,6 +7,7 @@ import { makeStyles } from '@material-ui/core/styles'
import { useToggleLove } from './useToggleLove'
import { useRecordContext } from 'react-admin'
import config from '../config'
import { isDateSet } from '../utils/validations'
const useStyles = makeStyles({
love: {
@@ -46,8 +47,13 @@ export const LoveButton = ({
<Button
onClick={handleToggleLove}
size={'small'}
disabled={disabled || loading || record?.missing}
disabled={disabled || loading || record.missing}
className={classes.love}
title={
isDateSet(record.starredAt)
? new Date(record.starredAt).toLocaleString()
: undefined
}
{...rest}
>
{record.starred ? (
+9 -1
View File
@@ -2,6 +2,7 @@ import React, { useCallback } from 'react'
import PropTypes from 'prop-types'
import Rating from '@material-ui/lab/Rating'
import { makeStyles } from '@material-ui/core/styles'
import { isDateSet } from '../utils/validations'
import StarBorderIcon from '@material-ui/icons/StarBorder'
import clsx from 'clsx'
import { useRating } from './useRating'
@@ -45,7 +46,14 @@ export const RatingField = ({
)
return (
<span onClick={(e) => stopPropagation(e)}>
<span
onClick={(e) => stopPropagation(e)}
title={
isDateSet(record.ratedAt)
? new Date(record.ratedAt).toLocaleString()
: undefined
}
>
<Rating
name={record.mediaFileId || record.id}
className={clsx(
+13
View File
@@ -10,3 +10,16 @@ export const urlValidate = (value) => {
return 'ra.validation.url'
}
}
export function isDateSet(date) {
if (!date) {
return false
}
if (typeof date === 'string') {
return date !== '0001-01-01T00:00:00Z'
}
if (date instanceof Date) {
return date.toISOString() !== '0001-01-01T00:00:00Z'
}
return !!date
}
+73
View File
@@ -0,0 +1,73 @@
import { isDateSet, urlValidate } from './validations'
describe('urlValidate', () => {
it('returns undefined for valid URLs', () => {
expect(urlValidate('https://example.com')).toBeUndefined()
expect(urlValidate('http://localhost:3000')).toBeUndefined()
expect(urlValidate('ftp://files.example.com')).toBeUndefined()
})
it('returns undefined for empty values', () => {
expect(urlValidate('')).toBeUndefined()
expect(urlValidate(null)).toBeUndefined()
expect(urlValidate(undefined)).toBeUndefined()
})
it('returns error for invalid URLs', () => {
expect(urlValidate('not-a-url')).toEqual('ra.validation.url')
expect(urlValidate('example.com')).toEqual('ra.validation.url')
expect(urlValidate('://missing-protocol')).toEqual('ra.validation.url')
})
})
describe('isDateSet', () => {
describe('with falsy values', () => {
it('returns false for null', () => {
expect(isDateSet(null)).toBe(false)
})
it('returns false for undefined', () => {
expect(isDateSet(undefined)).toBe(false)
})
it('returns false for empty string', () => {
expect(isDateSet('')).toBe(false)
})
})
describe('with Go zero date string', () => {
it('returns false for Go zero date', () => {
expect(isDateSet('0001-01-01T00:00:00Z')).toBe(false)
})
})
describe('with valid date strings', () => {
it('returns true for ISO date strings', () => {
expect(isDateSet('2024-01-15T10:30:00Z')).toBe(true)
expect(isDateSet('2023-12-25T00:00:00Z')).toBe(true)
})
it('returns true for other date formats', () => {
expect(isDateSet('2024-01-15')).toBe(true)
})
})
describe('with Date objects', () => {
it('returns true for valid Date objects', () => {
expect(isDateSet(new Date())).toBe(true)
expect(isDateSet(new Date('2024-01-15T10:30:00Z'))).toBe(true)
})
// Note: Date objects representing Go zero date would return true because
// toISOString() adds milliseconds (0001-01-01T00:00:00.000Z).
// In practice, dates from the API come as strings, not Date objects,
// so this edge case doesn't occur.
})
describe('with other truthy values', () => {
it('returns true for non-date truthy values', () => {
expect(isDateSet(123)).toBe(true)
expect(isDateSet({})).toBe(true)
})
})
})