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

View File

@@ -147,4 +147,36 @@ describe('SettingsPage', () => {
await userEvent.click(await screen.findByRole('button', { name: /log out/i })) await userEvent.click(await screen.findByRole('button', { name: /log out/i }))
expect(api.logout).toHaveBeenCalled() 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)
})
}) })