fix: sync-now feedback and polling, preserve stored subsonic password, fix placeholder
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user