1
0

fix: sync-now feedback and polling, preserve stored subsonic password, fix placeholder

This commit is contained in:
2026-08-30 00:16:41 +02:00
parent 6b85f11871
commit 3efde1a828
2 changed files with 61 additions and 10 deletions

View File

@@ -1,4 +1,4 @@
import { useCallback, useEffect, useState, type FormEvent, type ReactNode } from 'react'
import { useCallback, useEffect, useRef, useState, type FormEvent, type ReactNode } from 'react'
import { useNavigate } from 'react-router-dom'
import { api } from '../api.js'
import { useAuth } from '../auth.js'
@@ -32,6 +32,8 @@ export default function SettingsPage() {
const [newUsername, setNewUsername] = useState('')
const [newPassword, setNewPassword] = useState('')
const stopPollRef = useRef<(() => void) | null>(null)
const refreshSettings = useCallback(() => {
void api
.getSettings()
@@ -47,26 +49,32 @@ export default function SettingsPage() {
refreshSettings()
}, [refreshSettings])
useEffect(() => {
const pollSync = useCallback(() => {
let alive = true
const poll = (): void => {
const tick = (): void => {
void api
.syncStatus()
.then((s) => {
if (alive) setSync(s)
if (!alive) return s
setSync(s)
return s
})
.then((s) => {
if (alive && s?.status === 'running') setTimeout(poll, 2000)
if (alive && s?.status === 'running') setTimeout(tick, 2000)
})
.catch(() => {})
}
poll()
tick()
return () => {
alive = false
}
}, [])
useEffect(() => {
stopPollRef.current = pollSync()
return () => stopPollRef.current?.()
}, [pollSync])
useEffect(() => {
if (user?.isAdmin) {
void api
@@ -95,8 +103,10 @@ export default function SettingsPage() {
function saveSubsonic(e: FormEvent) {
e.preventDefault()
const payload: Parameters<typeof api.putSettings>[0] = { subsonicUrl, subsonicUsername }
if (subsonicPassword !== '') payload.subsonicPassword = subsonicPassword
void api
.putSettings({ subsonicUrl, subsonicUsername, subsonicPassword })
.putSettings(payload)
.then((v) => {
setView(v)
setSubsonicPassword('')
@@ -161,7 +171,7 @@ export default function SettingsPage() {
onChange={(e) => setDiscogsToken(e.target.value)}
className={inputCls}
autoComplete="off"
placeholder={view?.hasDiscogsToken ? 'Leave empty to keep, clear to remove' : 'Paste token'}
placeholder={view?.hasDiscogsToken ? 'Paste token (saving empty removes it)' : 'Paste token'}
/>
</label>
<button type="submit" className="rounded-xl bg-emerald-500 px-4 py-2 text-sm font-medium text-neutral-950">
@@ -188,7 +198,7 @@ export default function SettingsPage() {
onChange={(e) => setSubsonicPassword(e.target.value)}
className={inputCls}
autoComplete="new-password"
placeholder={view?.hasSubsonicPassword ? 'Saved — type to change' : ''}
placeholder={view?.hasSubsonicPassword ? 'Saved — leave blank to keep' : ''}
/>
</label>
<button type="submit" className="rounded-xl bg-emerald-500 px-4 py-2 text-sm font-medium text-neutral-950">
@@ -211,7 +221,16 @@ export default function SettingsPage() {
)}
<button
type="button"
onClick={() => void api.startSync().then(setSync).then(() => setTimeout(() => void api.syncStatus().then(setSync), 500))}
onClick={() =>
void api
.startSync()
.then((s) => {
setSync(s)
stopPollRef.current?.()
stopPollRef.current = pollSync()
})
.catch((err: unknown) => flash('error', err instanceof Error ? err.message : 'Sync failed'))
}
className="rounded-xl border border-neutral-700 px-3 py-1.5 text-sm text-neutral-300"
>
Sync now

View File

@@ -147,4 +147,36 @@ describe('SettingsPage', () => {
await userEvent.click(await screen.findByRole('button', { name: /log out/i }))
expect(api.logout).toHaveBeenCalled()
})
it('sync now surfaces the no-config error', async () => {
const err = new (await import('../src/api.js')).ApiError(409, 'no_subsonic_config')
vi.mocked(api.startSync).mockRejectedValue(err)
renderSettings()
await userEvent.click(await screen.findByRole('button', { name: /sync now/i }))
await waitFor(() => expect(screen.getByText(/no_subsonic_config/)).toBeTruthy())
})
it('keeps the stored subsonic password when saving with a blank password field', async () => {
vi.mocked(api.putSettings).mockResolvedValue(emptyView as never)
renderSettings()
await userEvent.type(await screen.findByLabelText(/subsonic url/i), 'http://navidrome.local')
await userEvent.type(screen.getByLabelText(/subsonic username/i), 'sam')
// password left blank
await userEvent.click(screen.getByRole('button', { name: /save subsonic/i }))
await waitFor(() =>
expect(api.putSettings).toHaveBeenCalledWith({ subsonicUrl: 'http://navidrome.local', subsonicUsername: 'sam' })
)
})
it('polls sync status until it finishes after sync now', async () => {
vi.mocked(api.startSync).mockResolvedValue({ ...idleSync, status: 'running' } as never)
vi.mocked(api.syncStatus)
.mockResolvedValueOnce({ ...idleSync } as never) // mount poll: idle, chain ends
.mockResolvedValueOnce({ ...idleSync, status: 'running' } as never) // first poll after sync now
.mockResolvedValue({ ...idleSync, status: 'done', albums: 42, lastSyncedAt: '2026-08-29T12:00:00.000Z' } as never)
renderSettings()
await userEvent.click(await screen.findByRole('button', { name: /sync now/i }))
await waitFor(() => expect(screen.getByText(/42 albums synced/i)).toBeTruthy(), { timeout: 3000 })
expect(vi.mocked(api.syncStatus).mock.calls.length).toBeGreaterThanOrEqual(3)
})
})