Compare commits

..

5 Commits

Author SHA1 Message Date
dependabot[bot]
dc07dc413d
chore(deps): bump golangci/golangci-lint-action in /.github/workflows (#4673)
Bumps [golangci/golangci-lint-action](https://github.com/golangci/golangci-lint-action) from 8 to 9.
- [Release notes](https://github.com/golangci/golangci-lint-action/releases)
- [Commits](https://github.com/golangci/golangci-lint-action/compare/v8...v9)

---
updated-dependencies:
- dependency-name: golangci/golangci-lint-action
  dependency-version: '9'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-11-24 23:36:19 -05:00
zacaj
3294bcacfc
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>
2025-11-24 23:18:05 -05:00
Deluan
228211f925 test: add smart playlist tag criteria tests for issue #4728
Add integration tests verifying the workaround for checking if a tag has any
value in smart playlists. The tests confirm that using 'contains' with an empty
string generates SQL that matches any non-empty tag value (value LIKE '%%'),
which is the recommended workaround for issue #4728.

Tests added:
- Verify contains with empty string matches tracks with tag values
- Verify notContains with empty string excludes tracks with tag values

Also updated test context to use GinkgoT().Context() instead of context.TODO().
2025-11-24 21:16:28 -05:00
dependabot[bot]
a6a682b385
chore(deps): bump actions/checkout from 5 to 6 in /.github/workflows (#4730)
Bumps [actions/checkout](https://github.com/actions/checkout) from 5 to 6.
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/checkout/compare/v5...v6)

---
updated-dependencies:
- dependency-name: actions/checkout
  dependency-version: '6'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-11-24 13:18:34 -05:00
Kendall Garner
c40f12e65b
fix(scanner): Use repeated arg instead of comma split (#4727) 2025-11-23 22:16:10 -05:00
19 changed files with 256 additions and 27 deletions

View File

@ -25,7 +25,7 @@ jobs:
git_tag: ${{ steps.git-version.outputs.GIT_TAG }} git_tag: ${{ steps.git-version.outputs.GIT_TAG }}
git_sha: ${{ steps.git-version.outputs.GIT_SHA }} git_sha: ${{ steps.git-version.outputs.GIT_SHA }}
steps: steps:
- uses: actions/checkout@v5 - uses: actions/checkout@v6
with: with:
fetch-depth: 0 fetch-depth: 0
fetch-tags: true fetch-tags: true
@ -63,7 +63,7 @@ jobs:
name: Lint Go code name: Lint Go code
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v5 - uses: actions/checkout@v6
- name: Download TagLib - name: Download TagLib
uses: ./.github/actions/download-taglib uses: ./.github/actions/download-taglib
@ -71,7 +71,7 @@ jobs:
version: ${{ env.CROSS_TAGLIB_VERSION }} version: ${{ env.CROSS_TAGLIB_VERSION }}
- name: golangci-lint - name: golangci-lint
uses: golangci/golangci-lint-action@v8 uses: golangci/golangci-lint-action@v9
with: with:
version: latest version: latest
problem-matchers: true problem-matchers: true
@ -93,7 +93,7 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Check out code into the Go module directory - name: Check out code into the Go module directory
uses: actions/checkout@v5 uses: actions/checkout@v6
- name: Download TagLib - name: Download TagLib
uses: ./.github/actions/download-taglib uses: ./.github/actions/download-taglib
@ -114,7 +114,7 @@ jobs:
env: env:
NODE_OPTIONS: "--max_old_space_size=4096" NODE_OPTIONS: "--max_old_space_size=4096"
steps: steps:
- uses: actions/checkout@v5 - uses: actions/checkout@v6
- uses: actions/setup-node@v6 - uses: actions/setup-node@v6
with: with:
node-version: 24 node-version: 24
@ -145,7 +145,7 @@ jobs:
name: Lint i18n files name: Lint i18n files
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v5 - uses: actions/checkout@v6
- run: | - run: |
set -e set -e
for file in resources/i18n/*.json; do for file in resources/i18n/*.json; do
@ -191,7 +191,7 @@ jobs:
PLATFORM=$(echo ${{ matrix.platform }} | tr '/' '_') PLATFORM=$(echo ${{ matrix.platform }} | tr '/' '_')
echo "PLATFORM=$PLATFORM" >> $GITHUB_ENV echo "PLATFORM=$PLATFORM" >> $GITHUB_ENV
- uses: actions/checkout@v5 - uses: actions/checkout@v6
- name: Prepare Docker Buildx - name: Prepare Docker Buildx
uses: ./.github/actions/prepare-docker uses: ./.github/actions/prepare-docker
@ -264,7 +264,7 @@ jobs:
env: env:
REGISTRY_IMAGE: ghcr.io/${{ github.repository }} REGISTRY_IMAGE: ghcr.io/${{ github.repository }}
steps: steps:
- uses: actions/checkout@v5 - uses: actions/checkout@v6
- name: Download digests - name: Download digests
uses: actions/download-artifact@v6 uses: actions/download-artifact@v6
@ -318,7 +318,7 @@ jobs:
runs-on: ubuntu-24.04 runs-on: ubuntu-24.04
steps: steps:
- uses: actions/checkout@v5 - uses: actions/checkout@v6
- uses: actions/download-artifact@v6 - uses: actions/download-artifact@v6
with: with:
@ -352,7 +352,7 @@ jobs:
outputs: outputs:
package_list: ${{ steps.set-package-list.outputs.package_list }} package_list: ${{ steps.set-package-list.outputs.package_list }}
steps: steps:
- uses: actions/checkout@v5 - uses: actions/checkout@v6
with: with:
fetch-depth: 0 fetch-depth: 0
fetch-tags: true fetch-tags: true

View File

@ -8,7 +8,7 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
if: ${{ github.repository_owner == 'navidrome' }} if: ${{ github.repository_owner == 'navidrome' }}
steps: steps:
- uses: actions/checkout@v5 - uses: actions/checkout@v6
- name: Get updated translations - name: Get updated translations
id: poeditor id: poeditor
env: env:

View File

@ -4,7 +4,6 @@ import (
"context" "context"
"encoding/gob" "encoding/gob"
"os" "os"
"strings"
"github.com/navidrome/navidrome/core" "github.com/navidrome/navidrome/core"
"github.com/navidrome/navidrome/db" "github.com/navidrome/navidrome/db"
@ -19,13 +18,13 @@ import (
var ( var (
fullScan bool fullScan bool
subprocess bool subprocess bool
targets string targets []string
) )
func init() { func init() {
scanCmd.Flags().BoolVarP(&fullScan, "full", "f", false, "check all subfolders, ignoring timestamps") scanCmd.Flags().BoolVarP(&fullScan, "full", "f", false, "check all subfolders, ignoring timestamps")
scanCmd.Flags().BoolVarP(&subprocess, "subprocess", "", false, "run as subprocess (internal use)") scanCmd.Flags().BoolVarP(&subprocess, "subprocess", "", false, "run as subprocess (internal use)")
scanCmd.Flags().StringVarP(&targets, "targets", "t", "", "comma-separated list of libraryID:folderPath pairs (e.g., \"1:Music/Rock,1:Music/Jazz,2:Classical\")") scanCmd.Flags().StringArrayVarP(&targets, "target", "t", []string{}, "list of libraryID:folderPath pairs, can be repeated (e.g., \"-t 1:Music/Rock -t 1:Music/Jazz -t 2:Classical\")")
rootCmd.AddCommand(scanCmd) rootCmd.AddCommand(scanCmd)
} }
@ -74,9 +73,9 @@ func runScanner(ctx context.Context) {
// Parse targets if provided // Parse targets if provided
var scanTargets []model.ScanTarget var scanTargets []model.ScanTarget
if targets != "" { if len(targets) > 0 {
var err error var err error
scanTargets, err = model.ParseTargets(strings.Split(targets, ",")) scanTargets, err = model.ParseTargets(targets)
if err != nil { if err != nil {
log.Fatal(ctx, "Failed to parse targets", err) log.Fatal(ctx, "Failed to parse targets", err)
} }

View File

@ -0,0 +1,7 @@
-- +goose Up
-- +goose StatementBegin
ALTER TABLE annotation ADD COLUMN rated_at datetime;
-- +goose StatementEnd
-- +goose Down

View File

@ -6,6 +6,7 @@ type Annotations struct {
PlayCount int64 `structs:"play_count" json:"playCount,omitempty"` PlayCount int64 `structs:"play_count" json:"playCount,omitempty"`
PlayDate *time.Time `structs:"play_date" json:"playDate,omitempty" ` PlayDate *time.Time `structs:"play_date" json:"playDate,omitempty" `
Rating int `structs:"rating" json:"rating,omitempty" ` Rating int `structs:"rating" json:"rating,omitempty" `
RatedAt *time.Time `structs:"rated_at" json:"ratedAt,omitempty" `
Starred bool `structs:"starred" json:"starred,omitempty" ` Starred bool `structs:"starred" json:"starred,omitempty" `
StarredAt *time.Time `structs:"starred_at" json:"starredAt,omitempty"` StarredAt *time.Time `structs:"starred_at" json:"starredAt,omitempty"`
} }

View File

@ -44,6 +44,7 @@ var fieldMap = map[string]*mappedField{
"loved": {field: "COALESCE(annotation.starred, false)"}, "loved": {field: "COALESCE(annotation.starred, false)"},
"dateloved": {field: "annotation.starred_at"}, "dateloved": {field: "annotation.starred_at"},
"lastplayed": {field: "annotation.play_date"}, "lastplayed": {field: "annotation.play_date"},
"daterated": {field: "annotation.rated_at"},
"playcount": {field: "COALESCE(annotation.play_count, 0)"}, "playcount": {field: "COALESCE(annotation.play_count, 0)"},
"rating": {field: "COALESCE(annotation.rating, 0)"}, "rating": {field: "COALESCE(annotation.rating, 0)"},
"mbz_album_id": {field: "media_file.mbz_album_id"}, "mbz_album_id": {field: "media_file.mbz_album_id"},

View File

@ -106,6 +106,7 @@ func NewAlbumRepository(ctx context.Context, db dbx.Builder) model.AlbumReposito
"random": "random", "random": "random",
"recently_added": recentlyAddedSort(), "recently_added": recentlyAddedSort(),
"starred_at": "starred, starred_at", "starred_at": "starred, starred_at",
"rated_at": "rating, rated_at",
}) })
return r return r
} }

View File

@ -141,6 +141,7 @@ func NewArtistRepository(ctx context.Context, db dbx.Builder) model.ArtistReposi
r.setSortMappings(map[string]string{ r.setSortMappings(map[string]string{
"name": "order_artist_name", "name": "order_artist_name",
"starred_at": "starred, starred_at", "starred_at": "starred, starred_at",
"rated_at": "rating, rated_at",
"song_count": "stats->>'total'->>'m'", "song_count": "stats->>'total'->>'m'",
"album_count": "stats->>'total'->>'a'", "album_count": "stats->>'total'->>'a'",
"size": "stats->>'total'->>'s'", "size": "stats->>'total'->>'s'",

View File

@ -84,6 +84,7 @@ func NewMediaFileRepository(ctx context.Context, db dbx.Builder) model.MediaFile
"created_at": "media_file.created_at", "created_at": "media_file.created_at",
"recently_added": mediaFileRecentlyAddedSort(), "recently_added": mediaFileRecentlyAddedSort(),
"starred_at": "starred, starred_at", "starred_at": "starred, starred_at",
"rated_at": "rating, rated_at",
}) })
return r return r
} }

View File

@ -388,6 +388,7 @@ func (r *playlistRepository) loadTracks(sel SelectBuilder, id string) (model.Pla
"coalesce(play_count, 0) as play_count", "coalesce(play_count, 0) as play_count",
"play_date", "play_date",
"coalesce(rating, 0) as rating", "coalesce(rating, 0) as rating",
"rated_at",
"f.*", "f.*",
"playlist_tracks.*", "playlist_tracks.*",
"library.path as library_path", "library.path as library_path",

View File

@ -1,7 +1,6 @@
package persistence package persistence
import ( import (
"context"
"time" "time"
"github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/conf"
@ -11,13 +10,14 @@ import (
"github.com/navidrome/navidrome/model/request" "github.com/navidrome/navidrome/model/request"
. "github.com/onsi/ginkgo/v2" . "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega" . "github.com/onsi/gomega"
"github.com/pocketbase/dbx"
) )
var _ = Describe("PlaylistRepository", func() { var _ = Describe("PlaylistRepository", func() {
var repo model.PlaylistRepository var repo model.PlaylistRepository
BeforeEach(func() { BeforeEach(func() {
ctx := log.NewContext(context.TODO()) ctx := log.NewContext(GinkgoT().Context())
ctx = request.WithUser(ctx, model.User{ID: "userid", UserName: "userid", IsAdmin: true}) ctx = request.WithUser(ctx, model.User{ID: "userid", UserName: "userid", IsAdmin: true})
repo = NewPlaylistRepository(ctx, GetDBXBuilder()) repo = NewPlaylistRepository(ctx, GetDBXBuilder())
}) })
@ -252,4 +252,118 @@ var _ = Describe("PlaylistRepository", func() {
Expect(tracks[3].MediaFileID).To(Equal("2001")) // Disc 2, Track 11 Expect(tracks[3].MediaFileID).To(Equal("2001")) // Disc 2, Track 11
}) })
}) })
Describe("Smart Playlists with Tag Criteria", func() {
var mfRepo model.MediaFileRepository
var testPlaylistID string
var songWithGrouping, songWithoutGrouping model.MediaFile
BeforeEach(func() {
ctx := log.NewContext(GinkgoT().Context())
ctx = request.WithUser(ctx, model.User{ID: "userid", UserName: "userid", IsAdmin: true})
mfRepo = NewMediaFileRepository(ctx, GetDBXBuilder())
// Register 'grouping' as a valid tag for smart playlists
criteria.AddTagNames([]string{"grouping"})
// Create a song with the grouping tag
songWithGrouping = model.MediaFile{
ID: "test-grouping-1",
Title: "Song With Grouping",
Artist: "Test Artist",
ArtistID: "1",
Album: "Test Album",
AlbumID: "101",
Path: "/test/grouping/song1.mp3",
Tags: model.Tags{
"grouping": []string{"My Crate"},
},
Participants: model.Participants{},
LibraryID: 1,
Lyrics: "[]",
}
Expect(mfRepo.Put(&songWithGrouping)).To(Succeed())
// Create a song without the grouping tag
songWithoutGrouping = model.MediaFile{
ID: "test-grouping-2",
Title: "Song Without Grouping",
Artist: "Test Artist",
ArtistID: "1",
Album: "Test Album",
AlbumID: "101",
Path: "/test/grouping/song2.mp3",
Tags: model.Tags{},
Participants: model.Participants{},
LibraryID: 1,
Lyrics: "[]",
}
Expect(mfRepo.Put(&songWithoutGrouping)).To(Succeed())
})
AfterEach(func() {
if testPlaylistID != "" {
_ = repo.Delete(testPlaylistID)
testPlaylistID = ""
}
// Clean up test media files
_, _ = GetDBXBuilder().Delete("media_file", dbx.HashExp{"id": "test-grouping-1"}).Execute()
_, _ = GetDBXBuilder().Delete("media_file", dbx.HashExp{"id": "test-grouping-2"}).Execute()
})
It("matches tracks with a tag value using 'contains' with empty string (issue #4728 workaround)", func() {
By("creating a smart playlist that checks if grouping tag has any value")
// This is the workaround for issue #4728: using 'contains' with empty string
// generates SQL: value LIKE '%%' which matches any non-empty string
rules := &criteria.Criteria{
Expression: criteria.All{
criteria.Contains{"grouping": ""},
},
}
newPls := model.Playlist{Name: "Tracks with Grouping", OwnerID: "userid", Rules: rules}
Expect(repo.Put(&newPls)).To(Succeed())
testPlaylistID = newPls.ID
By("refreshing the smart playlist")
conf.Server.SmartPlaylistRefreshDelay = -1 * time.Second // Force refresh
pls, err := repo.GetWithTracks(newPls.ID, true, false)
Expect(err).ToNot(HaveOccurred())
By("verifying only the track with grouping tag is matched")
Expect(pls.Tracks).To(HaveLen(1))
Expect(pls.Tracks[0].MediaFileID).To(Equal(songWithGrouping.ID))
})
It("excludes tracks with a tag value using 'notContains' with empty string", func() {
By("creating a smart playlist that checks if grouping tag is NOT set")
rules := &criteria.Criteria{
Expression: criteria.All{
criteria.NotContains{"grouping": ""},
},
}
newPls := model.Playlist{Name: "Tracks without Grouping", OwnerID: "userid", Rules: rules}
Expect(repo.Put(&newPls)).To(Succeed())
testPlaylistID = newPls.ID
By("refreshing the smart playlist")
conf.Server.SmartPlaylistRefreshDelay = -1 * time.Second // Force refresh
pls, err := repo.GetWithTracks(newPls.ID, true, false)
Expect(err).ToNot(HaveOccurred())
By("verifying the track with grouping is NOT in the playlist")
for _, track := range pls.Tracks {
Expect(track.MediaFileID).ToNot(Equal(songWithGrouping.ID))
}
By("verifying the track without grouping IS in the playlist")
var foundWithoutGrouping bool
for _, track := range pls.Tracks {
if track.MediaFileID == songWithoutGrouping.ID {
foundWithoutGrouping = true
break
}
}
Expect(foundWithoutGrouping).To(BeTrue())
})
})
}) })

View File

@ -97,6 +97,7 @@ func (r *playlistTrackRepository) Read(id string) (interface{}, error) {
"coalesce(rating, 0) as rating", "coalesce(rating, 0) as rating",
"starred_at", "starred_at",
"play_date", "play_date",
"rated_at",
"f.*", "f.*",
"playlist_tracks.*", "playlist_tracks.*",
). ).

View File

@ -28,6 +28,7 @@ func (r sqlRepository) withAnnotation(query SelectBuilder, idField string) Selec
"coalesce(rating, 0) as rating", "coalesce(rating, 0) as rating",
"starred_at", "starred_at",
"play_date", "play_date",
"rated_at",
) )
if conf.Server.AlbumPlayCountMode == consts.AlbumPlayCountModeNormalized && r.tableName == "album" { if conf.Server.AlbumPlayCountMode == consts.AlbumPlayCountModeNormalized && r.tableName == "album" {
query = query.Columns( query = query.Columns(
@ -77,7 +78,8 @@ func (r sqlRepository) SetStar(starred bool, ids ...string) error {
} }
func (r sqlRepository) SetRating(rating int, itemID string) error { func (r sqlRepository) SetRating(rating int, itemID string) error {
return r.annUpsert(map[string]interface{}{"rating": rating}, itemID) ratedAt := time.Now()
return r.annUpsert(map[string]interface{}{"rating": rating, "rated_at": ratedAt}, itemID)
} }
func (r sqlRepository) IncPlayCount(itemID string, ts time.Time) error { func (r sqlRepository) IncPlayCount(itemID string, ts time.Time) error {

View File

@ -8,12 +8,10 @@ import (
"io" "io"
"os" "os"
"os/exec" "os/exec"
"strings"
"github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/utils/slice"
) )
// scannerExternal is a scanner that runs an external process to do the scanning. It is used to avoid // scannerExternal is a scanner that runs an external process to do the scanning. It is used to avoid
@ -47,9 +45,10 @@ func (s *scannerExternal) scan(ctx context.Context, fullScan bool, targets []mod
// Add targets if provided // Add targets if provided
if len(targets) > 0 { if len(targets) > 0 {
targetsStr := strings.Join(slice.Map(targets, func(t model.ScanTarget) string { return t.String() }), ",") for _, target := range targets {
args = append(args, "--targets", targetsStr) args = append(args, "-t", target.String())
log.Debug(ctx, "Spawning external scanner process with targets", "fullScan", fullScan, "path", exe, "targets", targetsStr) }
log.Debug(ctx, "Spawning external scanner process with targets", "fullScan", fullScan, "path", exe, "targets", targets)
} else { } else {
log.Debug(ctx, "Spawning external scanner process", "fullScan", fullScan, "path", exe) log.Debug(ctx, "Spawning external scanner process", "fullScan", fullScan, "path", exe)
} }

View File

@ -1,10 +1,11 @@
import React from 'react' import React from 'react'
import { isDateSet } from '../utils/validations'
import { DateField as RADateField } from 'react-admin' import { DateField as RADateField } from 'react-admin'
export const DateField = (props) => { export const DateField = (props) => {
const { record, source } = props const { record, source } = props
const value = record?.[source] const value = record?.[source]
if (value === '0001-01-01T00:00:00Z' || value === null) return null if (!isDateSet(value)) return null
return <RADateField {...props} /> return <RADateField {...props} />
} }

View File

@ -7,6 +7,7 @@ import { makeStyles } from '@material-ui/core/styles'
import { useToggleLove } from './useToggleLove' import { useToggleLove } from './useToggleLove'
import { useRecordContext } from 'react-admin' import { useRecordContext } from 'react-admin'
import config from '../config' import config from '../config'
import { isDateSet } from '../utils/validations'
const useStyles = makeStyles({ const useStyles = makeStyles({
love: { love: {
@ -46,8 +47,13 @@ export const LoveButton = ({
<Button <Button
onClick={handleToggleLove} onClick={handleToggleLove}
size={'small'} size={'small'}
disabled={disabled || loading || record?.missing} disabled={disabled || loading || record.missing}
className={classes.love} className={classes.love}
title={
isDateSet(record.starredAt)
? new Date(record.starredAt).toLocaleString()
: undefined
}
{...rest} {...rest}
> >
{record.starred ? ( {record.starred ? (

View File

@ -2,6 +2,7 @@ import React, { useCallback } from 'react'
import PropTypes from 'prop-types' import PropTypes from 'prop-types'
import Rating from '@material-ui/lab/Rating' import Rating from '@material-ui/lab/Rating'
import { makeStyles } from '@material-ui/core/styles' import { makeStyles } from '@material-ui/core/styles'
import { isDateSet } from '../utils/validations'
import StarBorderIcon from '@material-ui/icons/StarBorder' import StarBorderIcon from '@material-ui/icons/StarBorder'
import clsx from 'clsx' import clsx from 'clsx'
import { useRating } from './useRating' import { useRating } from './useRating'
@ -45,7 +46,14 @@ export const RatingField = ({
) )
return ( return (
<span onClick={(e) => stopPropagation(e)}> <span
onClick={(e) => stopPropagation(e)}
title={
isDateSet(record.ratedAt)
? new Date(record.ratedAt).toLocaleString()
: undefined
}
>
<Rating <Rating
name={record.mediaFileId || record.id} name={record.mediaFileId || record.id}
className={clsx( className={clsx(

View File

@ -10,3 +10,16 @@ export const urlValidate = (value) => {
return 'ra.validation.url' 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
}

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)
})
})
})