mirror of
https://github.com/navidrome/navidrome.git
synced 2026-11-04 17:28:39 +01:00
Merge remote-tracking branch 'origin/master' into pr5949
# Conflicts: # cmd/wire_gen.go
This commit is contained in:
commit
bd6a3fb16e
58 changed files with 1732 additions and 543 deletions
|
|
@ -32,6 +32,8 @@ const defaultConfig = {
|
|||
listenBrainzEnabled: true,
|
||||
enableExternalServices: true,
|
||||
enableCoverAnimation: true,
|
||||
pidAlbum: 'musicbrainz_albumid|albumartistid,album,albumversion,releasedate', // See consts.DefaultAlbumPID
|
||||
pidTrack: 'musicbrainz_trackid|albumid,discnumber,tracknumber,title', // See consts.DefaultTrackPID
|
||||
enableNowPlaying: true,
|
||||
playbackReportIntervalMs: 60000,
|
||||
devShowArtistPage: true,
|
||||
|
|
|
|||
|
|
@ -369,11 +369,22 @@
|
|||
"totalDuration": "Duration",
|
||||
"defaultNewUsers": "Default for New Users",
|
||||
"createdAt": "Created",
|
||||
"updatedAt": "Updated"
|
||||
"updatedAt": "Updated",
|
||||
"pidAlbum": "Album grouping",
|
||||
"pidTrack": "Track identity"
|
||||
},
|
||||
"sections": {
|
||||
"basic": "Basic Information",
|
||||
"statistics": "Statistics"
|
||||
"statistics": "Statistics",
|
||||
"pid": "Persistent IDs"
|
||||
},
|
||||
"pid": {
|
||||
"global": "Use global setting (%{value})",
|
||||
"folder": "Folder (one album per folder)",
|
||||
"custom": "Custom",
|
||||
"spec": "PID spec",
|
||||
"help": "Tags and attributes that identify an item. See the documentation for the syntax:",
|
||||
"docs": "Persistent IDs"
|
||||
},
|
||||
"actions": {
|
||||
"scan": "Scan Library",
|
||||
|
|
@ -403,7 +414,9 @@
|
|||
"messages": {
|
||||
"deleteConfirm": "Are you sure you want to delete this library? This will remove all associated data and user access.",
|
||||
"scanInProgress": "Scan in progress...",
|
||||
"noLibrariesAssigned": "No libraries assigned to this user"
|
||||
"noLibrariesAssigned": "No libraries assigned to this user",
|
||||
"pidChangeTitle": "Change persistent IDs?",
|
||||
"pidChangeConfirm": "This regroups albums and tracks in this library. A full rescan of this library starts now. Track stars, ratings and play counts are kept. Album stars and ratings move to the new albums where an old album maps to a new one."
|
||||
}
|
||||
},
|
||||
"plugin": {
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import React, { useCallback } from 'react'
|
||||
import PropTypes from 'prop-types'
|
||||
import {
|
||||
Create,
|
||||
SimpleForm,
|
||||
|
|
@ -10,7 +11,34 @@ import {
|
|||
useNotify,
|
||||
useRedirect,
|
||||
} from 'react-admin'
|
||||
import { Typography } from '@material-ui/core'
|
||||
import { makeStyles } from '@material-ui/core/styles'
|
||||
import { Title } from '../common'
|
||||
import { PIDInputs } from './PIDInput'
|
||||
|
||||
const useStyles = makeStyles((theme) => ({
|
||||
spaced: { marginTop: theme.spacing(3) },
|
||||
}))
|
||||
|
||||
// SimpleForm passes form props (variant, record, ...) to its children, so Typography can't be used directly
|
||||
const SectionTitle = ({ label, spaced }) => {
|
||||
const translate = useTranslate()
|
||||
const classes = useStyles()
|
||||
return (
|
||||
<Typography
|
||||
variant="h6"
|
||||
gutterBottom
|
||||
className={spaced ? classes.spaced : undefined}
|
||||
>
|
||||
{translate(label)}
|
||||
</Typography>
|
||||
)
|
||||
}
|
||||
|
||||
SectionTitle.propTypes = {
|
||||
label: PropTypes.string.isRequired,
|
||||
spaced: PropTypes.bool,
|
||||
}
|
||||
|
||||
const LibraryCreate = (props) => {
|
||||
const translate = useTranslate()
|
||||
|
|
@ -73,9 +101,12 @@ const LibraryCreate = (props) => {
|
|||
return (
|
||||
<Create title={<Title subTitle={title} />} {...props}>
|
||||
<SimpleForm save={save} variant={'outlined'}>
|
||||
<SectionTitle label="resources.library.sections.basic" />
|
||||
<TextInput source="name" validate={[required()]} />
|
||||
<TextInput source="path" validate={[required()]} fullWidth />
|
||||
<BooleanInput source="defaultNewUsers" />
|
||||
<SectionTitle label="resources.library.sections.pid" spaced />
|
||||
<PIDInputs />
|
||||
</SimpleForm>
|
||||
</Create>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,9 +1,11 @@
|
|||
import React, { useCallback } from 'react'
|
||||
import React, { useCallback, useState } from 'react'
|
||||
import PropTypes from 'prop-types'
|
||||
import {
|
||||
Edit,
|
||||
FormWithRedirect,
|
||||
TextInput,
|
||||
BooleanInput,
|
||||
Confirm,
|
||||
required,
|
||||
SaveButton,
|
||||
useTranslate,
|
||||
|
|
@ -22,6 +24,9 @@ import {
|
|||
ReadOnlySizeField,
|
||||
Title,
|
||||
} from '../common'
|
||||
import config from '../config'
|
||||
import { PIDInputs } from './PIDInput'
|
||||
import { pidConfigChanged } from './pidPresets'
|
||||
|
||||
const useStyles = makeStyles({
|
||||
toolbar: {
|
||||
|
|
@ -53,8 +58,131 @@ const CustomToolbar = ({ showDelete, ...props }) => (
|
|||
</Toolbar>
|
||||
)
|
||||
|
||||
const LibraryEdit = (props) => {
|
||||
export const LibraryEditForm = ({ formProps, canEditPath, canDelete }) => {
|
||||
const translate = useTranslate()
|
||||
const [confirmOpen, setConfirmOpen] = useState(false)
|
||||
|
||||
// Every submit path (Save button and Enter key) goes through here, so a PID change always asks first
|
||||
const submit = () => {
|
||||
if (
|
||||
pidConfigChanged(
|
||||
formProps.form.getState().values,
|
||||
formProps.record,
|
||||
config,
|
||||
)
|
||||
) {
|
||||
setConfirmOpen(true)
|
||||
return
|
||||
}
|
||||
formProps.handleSubmit()
|
||||
}
|
||||
|
||||
const handleConfirm = () => {
|
||||
setConfirmOpen(false)
|
||||
formProps.handleSubmit()
|
||||
}
|
||||
|
||||
return (
|
||||
<form
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault()
|
||||
submit()
|
||||
}}
|
||||
>
|
||||
<Box p="1em" maxWidth="800px">
|
||||
<Box display="flex">
|
||||
<Box flex={1} mr="1em">
|
||||
{/* Basic Information */}
|
||||
<Typography variant="h6" gutterBottom>
|
||||
{translate('resources.library.sections.basic')}
|
||||
</Typography>
|
||||
|
||||
<TextInput
|
||||
source="name"
|
||||
label={translate('resources.library.fields.name')}
|
||||
validate={[required()]}
|
||||
variant="outlined"
|
||||
/>
|
||||
<TextInput
|
||||
source="path"
|
||||
label={translate('resources.library.fields.path')}
|
||||
validate={[required()]}
|
||||
fullWidth
|
||||
variant="outlined"
|
||||
InputProps={{ readOnly: !canEditPath }} // Disable editing path for library 1
|
||||
/>
|
||||
<BooleanInput
|
||||
source="defaultNewUsers"
|
||||
label={translate('resources.library.fields.defaultNewUsers')}
|
||||
variant="outlined"
|
||||
/>
|
||||
|
||||
<Box mt="2em" />
|
||||
<Typography variant="h6" gutterBottom>
|
||||
{translate('resources.library.sections.pid')}
|
||||
</Typography>
|
||||
<PIDInputs />
|
||||
|
||||
<Box mt="2em" />
|
||||
|
||||
{/* Statistics - Two Column Layout */}
|
||||
<Typography variant="h6" gutterBottom>
|
||||
{translate('resources.library.sections.statistics')}
|
||||
</Typography>
|
||||
|
||||
<Box
|
||||
display="grid"
|
||||
gridTemplateColumns="1fr 1fr"
|
||||
gridColumnGap="1em"
|
||||
>
|
||||
<ReadOnlyNumberField source="totalSongs" {...readOnlyProps} />
|
||||
<ReadOnlyNumberField source="totalAlbums" {...readOnlyProps} />
|
||||
<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>
|
||||
</Box>
|
||||
|
||||
<CustomToolbar
|
||||
handleSubmitWithRedirect={submit}
|
||||
pristine={formProps.pristine}
|
||||
saving={formProps.saving}
|
||||
record={formProps.record}
|
||||
showDelete={canDelete}
|
||||
/>
|
||||
<Confirm
|
||||
isOpen={confirmOpen}
|
||||
loading={formProps.saving}
|
||||
title="resources.library.messages.pidChangeTitle"
|
||||
content="resources.library.messages.pidChangeConfirm"
|
||||
onConfirm={handleConfirm}
|
||||
onClose={() => setConfirmOpen(false)}
|
||||
/>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
|
||||
LibraryEditForm.propTypes = {
|
||||
formProps: PropTypes.object.isRequired,
|
||||
canEditPath: PropTypes.bool,
|
||||
canDelete: PropTypes.bool,
|
||||
}
|
||||
|
||||
const LibraryEdit = (props) => {
|
||||
const [mutate] = useMutation()
|
||||
const notify = useNotify()
|
||||
const redirect = useRedirect()
|
||||
|
|
@ -93,91 +221,11 @@ const LibraryEdit = (props) => {
|
|||
{...props}
|
||||
save={save}
|
||||
render={(formProps) => (
|
||||
<form onSubmit={formProps.handleSubmit}>
|
||||
<Box p="1em" maxWidth="800px">
|
||||
<Box display="flex">
|
||||
<Box flex={1} mr="1em">
|
||||
{/* Basic Information */}
|
||||
<Typography variant="h6" gutterBottom>
|
||||
{translate('resources.library.sections.basic')}
|
||||
</Typography>
|
||||
|
||||
<TextInput
|
||||
source="name"
|
||||
label={translate('resources.library.fields.name')}
|
||||
validate={[required()]}
|
||||
variant="outlined"
|
||||
/>
|
||||
<TextInput
|
||||
source="path"
|
||||
label={translate('resources.library.fields.path')}
|
||||
validate={[required()]}
|
||||
fullWidth
|
||||
variant="outlined"
|
||||
InputProps={{ readOnly: !canEditPath }} // Disable editing path for library 1
|
||||
/>
|
||||
<BooleanInput
|
||||
source="defaultNewUsers"
|
||||
label={translate(
|
||||
'resources.library.fields.defaultNewUsers',
|
||||
)}
|
||||
variant="outlined"
|
||||
/>
|
||||
|
||||
<Box mt="2em" />
|
||||
|
||||
{/* Statistics - Two Column Layout */}
|
||||
<Typography variant="h6" gutterBottom>
|
||||
{translate('resources.library.sections.statistics')}
|
||||
</Typography>
|
||||
|
||||
<Box
|
||||
display="grid"
|
||||
gridTemplateColumns="1fr 1fr"
|
||||
gridColumnGap="1em"
|
||||
>
|
||||
<ReadOnlyNumberField
|
||||
source="totalSongs"
|
||||
{...readOnlyProps}
|
||||
/>
|
||||
<ReadOnlyNumberField
|
||||
source="totalAlbums"
|
||||
{...readOnlyProps}
|
||||
/>
|
||||
<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>
|
||||
</Box>
|
||||
|
||||
<CustomToolbar
|
||||
handleSubmitWithRedirect={formProps.handleSubmitWithRedirect}
|
||||
pristine={formProps.pristine}
|
||||
saving={formProps.saving}
|
||||
record={formProps.record}
|
||||
showDelete={canDelete}
|
||||
/>
|
||||
</form>
|
||||
<LibraryEditForm
|
||||
formProps={formProps}
|
||||
canEditPath={canEditPath}
|
||||
canDelete={canDelete}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</Edit>
|
||||
|
|
|
|||
125
ui/src/library/LibraryEdit.test.jsx
Normal file
125
ui/src/library/LibraryEdit.test.jsx
Normal file
|
|
@ -0,0 +1,125 @@
|
|||
import * as React from 'react'
|
||||
import { TestContext } from 'ra-test'
|
||||
import {
|
||||
FormWithRedirect,
|
||||
RecordContextProvider,
|
||||
SaveContextProvider,
|
||||
} from 'react-admin'
|
||||
import {
|
||||
cleanup,
|
||||
fireEvent,
|
||||
render,
|
||||
screen,
|
||||
waitFor,
|
||||
within,
|
||||
} from '@testing-library/react'
|
||||
import { describe, it, expect, vi, afterEach } from 'vitest'
|
||||
import { LibraryEditForm } from './LibraryEdit'
|
||||
import config from '../config'
|
||||
|
||||
const record = {
|
||||
id: '2',
|
||||
name: 'Jazz',
|
||||
path: '/music/jazz',
|
||||
pidAlbum: '',
|
||||
pidTrack: '',
|
||||
}
|
||||
|
||||
// Edit provides a save context in the app. SaveButton only reads these setters from it
|
||||
const saveContext = {
|
||||
save: vi.fn(),
|
||||
setOnSuccess: vi.fn(),
|
||||
setOnFailure: vi.fn(),
|
||||
setTransform: vi.fn(),
|
||||
}
|
||||
|
||||
const renderForm = (save) =>
|
||||
render(
|
||||
<TestContext>
|
||||
<SaveContextProvider value={saveContext}>
|
||||
<RecordContextProvider value={record}>
|
||||
<FormWithRedirect
|
||||
record={record}
|
||||
save={save}
|
||||
render={(formProps) => (
|
||||
<LibraryEditForm
|
||||
formProps={formProps}
|
||||
canEditPath
|
||||
canDelete={false}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</RecordContextProvider>
|
||||
</SaveContextProvider>
|
||||
</TestContext>,
|
||||
)
|
||||
|
||||
const chooseAlbumGrouping = (optionText) => {
|
||||
fireEvent.mouseDown(
|
||||
screen.getByLabelText('resources.library.fields.pidAlbum'),
|
||||
)
|
||||
fireEvent.click(within(screen.getByRole('listbox')).getByText(optionText))
|
||||
}
|
||||
|
||||
const dialogTitle = 'resources.library.messages.pidChangeTitle'
|
||||
|
||||
describe('LibraryEditForm', () => {
|
||||
afterEach(cleanup)
|
||||
|
||||
it('saves directly when the PID config did not change', async () => {
|
||||
const save = vi.fn()
|
||||
renderForm(save)
|
||||
fireEvent.change(screen.getByLabelText(/resources.library.fields.name/), {
|
||||
target: { value: 'Jazz Renamed' },
|
||||
})
|
||||
fireEvent.click(screen.getByText('ra.action.save'))
|
||||
await waitFor(() => expect(save).toHaveBeenCalled())
|
||||
expect(screen.queryByText(dialogTitle)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('asks before saving a PID change, and Cancel keeps the edits', async () => {
|
||||
const save = vi.fn()
|
||||
renderForm(save)
|
||||
chooseAlbumGrouping('resources.library.pid.folder')
|
||||
fireEvent.click(screen.getByText('ra.action.save'))
|
||||
|
||||
expect(await screen.findByText(dialogTitle)).toBeInTheDocument()
|
||||
expect(save).not.toHaveBeenCalled()
|
||||
|
||||
fireEvent.click(screen.getByText('ra.action.cancel'))
|
||||
await waitFor(() =>
|
||||
expect(screen.queryByText(dialogTitle)).not.toBeInTheDocument(),
|
||||
)
|
||||
expect(save).not.toHaveBeenCalled()
|
||||
expect(screen.getByText('resources.library.pid.folder')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('saves the PID change after Confirm', async () => {
|
||||
const save = vi.fn()
|
||||
renderForm(save)
|
||||
chooseAlbumGrouping('resources.library.pid.folder')
|
||||
fireEvent.click(screen.getByText('ra.action.save'))
|
||||
fireEvent.click(await screen.findByText('ra.action.confirm'))
|
||||
|
||||
await waitFor(() => expect(save).toHaveBeenCalled())
|
||||
expect(save.mock.calls[0][0]).toMatchObject({ pidAlbum: 'folder' })
|
||||
})
|
||||
|
||||
it('pre-fills a Custom spec with the global spec', () => {
|
||||
renderForm(vi.fn())
|
||||
chooseAlbumGrouping('resources.library.pid.custom')
|
||||
expect(screen.getByLabelText(/resources.library.pid.spec/)).toHaveValue(
|
||||
config.pidAlbum,
|
||||
)
|
||||
})
|
||||
|
||||
it('asks before saving when the form is submitted with Enter', async () => {
|
||||
const save = vi.fn()
|
||||
const { container } = renderForm(save)
|
||||
chooseAlbumGrouping('resources.library.pid.folder')
|
||||
fireEvent.submit(container.querySelector('form'))
|
||||
|
||||
expect(await screen.findByText(dialogTitle)).toBeInTheDocument()
|
||||
expect(save).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
114
ui/src/library/PIDInput.jsx
Normal file
114
ui/src/library/PIDInput.jsx
Normal file
|
|
@ -0,0 +1,114 @@
|
|||
import React, { useState } from 'react'
|
||||
import PropTypes from 'prop-types'
|
||||
import { TextInput, required, useTranslate } from 'react-admin'
|
||||
import { useField } from 'react-final-form'
|
||||
import { FormHelperText, Link, MenuItem, TextField } from '@material-ui/core'
|
||||
import { makeStyles } from '@material-ui/core/styles'
|
||||
import {
|
||||
PID_CUSTOM,
|
||||
PID_FOLDER,
|
||||
PID_GLOBAL,
|
||||
pidModeFromValue,
|
||||
pidValueForMode,
|
||||
} from './pidPresets'
|
||||
import config from '../config'
|
||||
import { docsUrl } from '../utils'
|
||||
|
||||
const PID_DOCS_URL = docsUrl('/docs/usage/pids/')
|
||||
|
||||
const useStyles = makeStyles((theme) => ({
|
||||
help: { marginBottom: theme.spacing(1) },
|
||||
}))
|
||||
|
||||
// PIDInput edits a library PID override: use the global setting, a preset, or a custom spec
|
||||
export const PIDInput = ({ source, label, globalValue, allowFolder }) => {
|
||||
const translate = useTranslate()
|
||||
const classes = useStyles()
|
||||
const { input } = useField(source)
|
||||
// Local state, so choosing Custom shows the text box before anything is typed
|
||||
const [mode, setMode] = useState(() =>
|
||||
pidModeFromValue(input.value, allowFolder),
|
||||
)
|
||||
|
||||
const choices = [
|
||||
{
|
||||
id: PID_GLOBAL,
|
||||
name: translate('resources.library.pid.global', { value: globalValue }),
|
||||
},
|
||||
...(allowFolder
|
||||
? [{ id: PID_FOLDER, name: translate('resources.library.pid.folder') }]
|
||||
: []),
|
||||
{ id: PID_CUSTOM, name: translate('resources.library.pid.custom') },
|
||||
]
|
||||
|
||||
const handleModeChange = (event) => {
|
||||
const newMode = event.target.value
|
||||
setMode(newMode)
|
||||
input.onChange(pidValueForMode(newMode, globalValue))
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<TextField
|
||||
id={`${source}-mode`}
|
||||
select
|
||||
fullWidth
|
||||
variant="outlined"
|
||||
margin="dense"
|
||||
label={label}
|
||||
value={mode}
|
||||
onChange={handleModeChange}
|
||||
>
|
||||
{choices.map((choice) => (
|
||||
<MenuItem key={choice.id} value={choice.id}>
|
||||
{choice.name}
|
||||
</MenuItem>
|
||||
))}
|
||||
</TextField>
|
||||
{mode === PID_CUSTOM && (
|
||||
<>
|
||||
<TextInput
|
||||
source={source}
|
||||
label={translate('resources.library.pid.spec')}
|
||||
validate={[required()]}
|
||||
fullWidth
|
||||
variant="outlined"
|
||||
helperText={false}
|
||||
/>
|
||||
<FormHelperText className={classes.help}>
|
||||
{translate('resources.library.pid.help')}{' '}
|
||||
<Link href={PID_DOCS_URL} target="_blank" rel="noopener noreferrer">
|
||||
{translate('resources.library.pid.docs')}
|
||||
</Link>
|
||||
</FormHelperText>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
PIDInput.propTypes = {
|
||||
source: PropTypes.string.isRequired,
|
||||
label: PropTypes.string.isRequired,
|
||||
globalValue: PropTypes.string,
|
||||
allowFolder: PropTypes.bool,
|
||||
}
|
||||
|
||||
export const PIDInputs = () => {
|
||||
const translate = useTranslate()
|
||||
return (
|
||||
<>
|
||||
<PIDInput
|
||||
source="pidAlbum"
|
||||
label={translate('resources.library.fields.pidAlbum')}
|
||||
globalValue={config.pidAlbum}
|
||||
allowFolder
|
||||
/>
|
||||
<PIDInput
|
||||
source="pidTrack"
|
||||
label={translate('resources.library.fields.pidTrack')}
|
||||
globalValue={config.pidTrack}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
33
ui/src/library/pidPresets.js
Normal file
33
ui/src/library/pidPresets.js
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
export const PID_GLOBAL = 'global'
|
||||
export const PID_FOLDER = 'folder'
|
||||
export const PID_CUSTOM = 'custom'
|
||||
|
||||
export const pidModeFromValue = (value, allowFolder) => {
|
||||
const v = (value || '').trim()
|
||||
if (v === '') return PID_GLOBAL
|
||||
if (allowFolder && v === PID_FOLDER) return PID_FOLDER
|
||||
return PID_CUSTOM
|
||||
}
|
||||
|
||||
// Returns the value to store for a dropdown choice. Custom starts from the global spec
|
||||
export const pidValueForMode = (mode, globalValue) => {
|
||||
switch (mode) {
|
||||
case PID_GLOBAL:
|
||||
return ''
|
||||
case PID_FOLDER:
|
||||
return PID_FOLDER
|
||||
default:
|
||||
return globalValue || ''
|
||||
}
|
||||
}
|
||||
|
||||
// Reports whether the form values change the effective PID spec of the saved record. Like the
|
||||
// server, it trims, treats empty as the global value and compares case-insensitively
|
||||
export const pidConfigChanged = (values, record, globals) => {
|
||||
const effective = (value, field) =>
|
||||
((value || '').trim() || globals[field] || '').toLowerCase()
|
||||
return ['pidAlbum', 'pidTrack'].some(
|
||||
(field) =>
|
||||
effective(values[field], field) !== effective(record[field], field),
|
||||
)
|
||||
}
|
||||
65
ui/src/library/pidPresets.test.js
Normal file
65
ui/src/library/pidPresets.test.js
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
import { describe, it, expect } from 'vitest'
|
||||
import {
|
||||
PID_CUSTOM,
|
||||
PID_FOLDER,
|
||||
PID_GLOBAL,
|
||||
pidConfigChanged,
|
||||
pidModeFromValue,
|
||||
pidValueForMode,
|
||||
} from './pidPresets'
|
||||
|
||||
describe('pidModeFromValue', () => {
|
||||
it('maps an empty value to the global setting', () => {
|
||||
expect(pidModeFromValue('', true)).toBe(PID_GLOBAL)
|
||||
expect(pidModeFromValue(undefined, true)).toBe(PID_GLOBAL)
|
||||
})
|
||||
it('maps folder to the Folder preset when allowed', () => {
|
||||
expect(pidModeFromValue('folder', true)).toBe(PID_FOLDER)
|
||||
})
|
||||
it('maps folder to Custom when the Folder preset is not offered', () => {
|
||||
expect(pidModeFromValue('folder', false)).toBe(PID_CUSTOM)
|
||||
})
|
||||
it('maps any other value to Custom', () => {
|
||||
expect(pidModeFromValue('album|title', true)).toBe(PID_CUSTOM)
|
||||
})
|
||||
})
|
||||
|
||||
describe('pidValueForMode', () => {
|
||||
it('stores an empty value for the global setting', () => {
|
||||
expect(pidValueForMode(PID_GLOBAL, 'album')).toBe('')
|
||||
})
|
||||
it('stores folder for the Folder preset', () => {
|
||||
expect(pidValueForMode(PID_FOLDER, '')).toBe('folder')
|
||||
})
|
||||
it('starts Custom from the global spec', () => {
|
||||
expect(pidValueForMode(PID_CUSTOM, 'album|title')).toBe('album|title')
|
||||
expect(pidValueForMode(PID_CUSTOM, undefined)).toBe('')
|
||||
})
|
||||
})
|
||||
|
||||
describe('pidConfigChanged', () => {
|
||||
const record = { pidAlbum: 'folder', pidTrack: '' }
|
||||
const globals = {
|
||||
pidAlbum: 'musicbrainz_albumid|albumartistid,album',
|
||||
pidTrack: 'musicbrainz_trackid|albumid,discnumber,tracknumber,title',
|
||||
}
|
||||
it.each([
|
||||
['nothing changed', { pidAlbum: 'folder', pidTrack: '' }, false],
|
||||
['a missing value equals an empty one', { pidAlbum: 'folder' }, false],
|
||||
[
|
||||
'Custom set to the global value',
|
||||
{ pidAlbum: 'folder', pidTrack: globals.pidTrack },
|
||||
false,
|
||||
],
|
||||
['a case-only change', { pidAlbum: 'FOLDER', pidTrack: '' }, false],
|
||||
[
|
||||
'a whitespace-only change',
|
||||
{ pidAlbum: ' folder ', pidTrack: ' ' },
|
||||
false,
|
||||
],
|
||||
['the album PID changed', { pidAlbum: '', pidTrack: '' }, true],
|
||||
['the track PID changed', { pidAlbum: 'folder', pidTrack: 'title' }, true],
|
||||
])('%s', (_, values, expected) => {
|
||||
expect(pidConfigChanged(values, record, globals)).toBe(expected)
|
||||
})
|
||||
})
|
||||
Loading…
Add table
Add a link
Reference in a new issue