diff --git a/webui/src/routes/import/-route.test.tsx b/webui/src/routes/import/-route.test.tsx index 35566c14..679341c5 100644 --- a/webui/src/routes/import/-route.test.tsx +++ b/webui/src/routes/import/-route.test.tsx @@ -9,6 +9,7 @@ import { createShellBridge } from '@/test/shell-bridge'; import type { ImportStagingFile } from './-import.types'; +import { autoImportResultsQueryOptions, autoImportStatusQueryOptions } from './-import.api'; import { resetImportWorkflowStore } from './-import.store'; function renderImportRoute(initialEntries = ['/import']) { @@ -19,6 +20,7 @@ function renderImportRoute(initialEntries = ['/import']) { return { history, router, + queryClient, ...render(), }; } @@ -209,6 +211,26 @@ describe('import route', () => { expect(window.SoulSyncWebShellBridge?.setActivePageChrome).toHaveBeenCalledWith('import'); }); + it('keeps the import page rendering when staging files fail to load', async () => { + server.use( + http.get('/api/import/staging/files', () => + HttpResponse.json( + { + success: false, + error: 'Import folder unavailable', + }, + { status: 500 }, + ), + ), + ); + + renderImportRoute(); + + expect(await screen.findByTestId('import-page')).toBeInTheDocument(); + expect(await screen.findByText('Import Music')).toBeInTheDocument(); + expect(await screen.findByText('Import folder: error')).toBeInTheDocument(); + }); + it('stores the active tab in nested route paths', async () => { const { history } = renderImportRoute(); @@ -318,4 +340,56 @@ describe('import route', () => { false, ); }); + + it('keeps cached auto-import status visible when a refetch fails', async () => { + const { queryClient } = renderImportRoute(['/import/auto']); + + expect(await screen.findByText('Watching')).toBeInTheDocument(); + + server.use( + http.get('/api/auto-import/status', () => + HttpResponse.json( + { + success: false, + error: 'Auto-import unavailable', + }, + { status: 500 }, + ), + ), + ); + + await queryClient.refetchQueries({ + queryKey: autoImportStatusQueryOptions().queryKey, + }); + + expect(screen.getByText('Watching')).toBeInTheDocument(); + expect(screen.queryByText(/Auto-import is unavailable:/)).not.toBeInTheDocument(); + }); + + it('keeps cached auto-import results visible when a refetch fails', async () => { + const { queryClient } = renderImportRoute(['/import/auto?autoFilter=pending']); + + expect(await screen.findByRole('button', { name: /^Needs Review\s*1$/ })).toBeInTheDocument(); + expect(screen.getAllByText('Album A').length).toBeGreaterThan(0); + + server.use( + http.get('/api/auto-import/results', () => + HttpResponse.json( + { + success: false, + error: 'Auto-import results unavailable', + }, + { status: 500 }, + ), + ), + ); + + await queryClient.refetchQueries({ + queryKey: autoImportResultsQueryOptions().queryKey, + }); + + expect(screen.getByRole('button', { name: /^Needs Review\s*1$/ })).toBeInTheDocument(); + expect(screen.getAllByText('Album A').length).toBeGreaterThan(0); + expect(screen.queryByText(/Failed to load imports:/)).not.toBeInTheDocument(); + }); }); diff --git a/webui/src/routes/import/-ui/auto-import-tab.tsx b/webui/src/routes/import/-ui/auto-import-tab.tsx index 761a08df..245861e3 100644 --- a/webui/src/routes/import/-ui/auto-import-tab.tsx +++ b/webui/src/routes/import/-ui/auto-import-tab.tsx @@ -10,7 +10,7 @@ import { Select, Switch, } from '@/components/form/form'; -import { Badge } from '@/components/primitives'; +import { Badge, Notice } from '@/components/primitives'; import type { ImportAutoFilter, @@ -151,17 +151,18 @@ export function AutoImportPanel({ const counts = getAutoImportCounts(allResults); const activeLines = getActiveImportLines(statusQuery.data); const statusTone = getAutoImportStatusTone(statusQuery.data); - - if (statusQuery.error) { - return ( -
- Auto-import is unavailable: {getErrorMessage(statusQuery.error)} -
- ); - } + const statusError = + statusQuery.error && !statusQuery.data ? getErrorMessage(statusQuery.error) : ''; + const resultsError = + resultsQuery.error && !resultsQuery.data ? getErrorMessage(resultsQuery.error) : ''; return ( <> + {statusError ? ( + + Auto-import is unavailable: {statusError} + + ) : null}
@@ -293,10 +294,10 @@ export function AutoImportPanel({ ) : null}
- {resultsQuery.error ? ( -
- Failed to load imports: {getErrorMessage(resultsQuery.error)} -
+ {resultsError ? ( + + Failed to load imports: {resultsError} + ) : allResults.length === 0 ? (

No imports yet. Drop album folders or single tracks into your import folder.

diff --git a/webui/src/routes/import/-ui/import-shared.tsx b/webui/src/routes/import/-ui/import-shared.tsx index dfa5bdda..61b3b93e 100644 --- a/webui/src/routes/import/-ui/import-shared.tsx +++ b/webui/src/routes/import/-ui/import-shared.tsx @@ -1,6 +1,6 @@ import { useQuery, useQueryClient } from '@tanstack/react-query'; -import type { ImportQueueJob } from '../-import.types'; +import type { ImportQueueJob, ImportStagingFile } from '../-import.types'; import { importStagingFilesQueryOptions, @@ -11,6 +11,8 @@ import { import { getTrackDisplayInfo, IMPORT_PLACEHOLDER_IMAGE } from '../-import.helpers'; import { useImportQueueWorkflow, useImportWorkflowStore } from '../-import.store'; +const EMPTY_STAGING_FILES: ImportStagingFile[] = []; + export function useImportStaging() { const queryClient = useQueryClient(); const clearFinishedJobs = useImportWorkflowStore((state) => state.clearFinishedJobs); @@ -23,7 +25,8 @@ export function useImportStaging() { clearFinishedJobs(); await invalidateImportStagingQueries(queryClient); }, - stagingFiles: stagingQuery.data?.files ?? [], + // Keep the empty fallback stable so staging-driven effects do not loop while loading. + stagingFiles: stagingQuery.data?.files ?? EMPTY_STAGING_FILES, stagingPath: stagingQuery.data?.staging_path || 'Not configured', stagingQuery, }; diff --git a/webui/src/routes/import/route.tsx b/webui/src/routes/import/route.tsx index 5cb5e60d..e91f9658 100644 --- a/webui/src/routes/import/route.tsx +++ b/webui/src/routes/import/route.tsx @@ -13,8 +13,10 @@ export const Route = createFileRoute('/import')({ throw redirect({ href: getProfileHomePath(bridge), replace: true }); } }, - loader: async ({ context }) => { - await context.queryClient.ensureQueryData(importStagingFilesQueryOptions()); + loader: ({ context }) => { + // Warm the staging query if possible, but never block the route on a transient fetch + // failure. The page owns the in-place error state for that case. + void context.queryClient.prefetchQuery(importStagingFilesQueryOptions()); }, component: ImportPage, });