fix(webui): keep import pages cache-aware
- keep the /import loader from turning transient staging fetch failures into route errors - keep cached auto-import status and results visible during refetch failures - show inline notices only when there is no stale data to fall back to - add regression coverage for staging, status, and results failure paths
This commit is contained in:
parent
a65fbfd532
commit
e2a760bd68
4 changed files with 97 additions and 17 deletions
|
|
@ -9,6 +9,7 @@ import { createShellBridge } from '@/test/shell-bridge';
|
||||||
|
|
||||||
import type { ImportStagingFile } from './-import.types';
|
import type { ImportStagingFile } from './-import.types';
|
||||||
|
|
||||||
|
import { autoImportResultsQueryOptions, autoImportStatusQueryOptions } from './-import.api';
|
||||||
import { resetImportWorkflowStore } from './-import.store';
|
import { resetImportWorkflowStore } from './-import.store';
|
||||||
|
|
||||||
function renderImportRoute(initialEntries = ['/import']) {
|
function renderImportRoute(initialEntries = ['/import']) {
|
||||||
|
|
@ -19,6 +20,7 @@ function renderImportRoute(initialEntries = ['/import']) {
|
||||||
return {
|
return {
|
||||||
history,
|
history,
|
||||||
router,
|
router,
|
||||||
|
queryClient,
|
||||||
...render(<AppRouterProvider router={router} queryClient={queryClient} />),
|
...render(<AppRouterProvider router={router} queryClient={queryClient} />),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
@ -209,6 +211,26 @@ describe('import route', () => {
|
||||||
expect(window.SoulSyncWebShellBridge?.setActivePageChrome).toHaveBeenCalledWith('import');
|
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 () => {
|
it('stores the active tab in nested route paths', async () => {
|
||||||
const { history } = renderImportRoute();
|
const { history } = renderImportRoute();
|
||||||
|
|
||||||
|
|
@ -318,4 +340,56 @@ describe('import route', () => {
|
||||||
false,
|
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();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,7 @@ import {
|
||||||
Select,
|
Select,
|
||||||
Switch,
|
Switch,
|
||||||
} from '@/components/form/form';
|
} from '@/components/form/form';
|
||||||
import { Badge } from '@/components/primitives';
|
import { Badge, Notice } from '@/components/primitives';
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
ImportAutoFilter,
|
ImportAutoFilter,
|
||||||
|
|
@ -151,17 +151,18 @@ export function AutoImportPanel({
|
||||||
const counts = getAutoImportCounts(allResults);
|
const counts = getAutoImportCounts(allResults);
|
||||||
const activeLines = getActiveImportLines(statusQuery.data);
|
const activeLines = getActiveImportLines(statusQuery.data);
|
||||||
const statusTone = getAutoImportStatusTone(statusQuery.data);
|
const statusTone = getAutoImportStatusTone(statusQuery.data);
|
||||||
|
const statusError =
|
||||||
if (statusQuery.error) {
|
statusQuery.error && !statusQuery.data ? getErrorMessage(statusQuery.error) : '';
|
||||||
return (
|
const resultsError =
|
||||||
<div className={styles.autoImportEmpty}>
|
resultsQuery.error && !resultsQuery.data ? getErrorMessage(resultsQuery.error) : '';
|
||||||
Auto-import is unavailable: {getErrorMessage(statusQuery.error)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
{statusError ? (
|
||||||
|
<Notice tone="danger" role="alert">
|
||||||
|
Auto-import is unavailable: {statusError}
|
||||||
|
</Notice>
|
||||||
|
) : null}
|
||||||
<div className={styles.autoImportControls}>
|
<div className={styles.autoImportControls}>
|
||||||
<div className={styles.autoImportToggleRow}>
|
<div className={styles.autoImportToggleRow}>
|
||||||
<div className={styles.autoImportToggleLabel}>
|
<div className={styles.autoImportToggleLabel}>
|
||||||
|
|
@ -293,10 +294,10 @@ export function AutoImportPanel({
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<div className={styles.autoImportResults} id="auto-import-results">
|
<div className={styles.autoImportResults} id="auto-import-results">
|
||||||
{resultsQuery.error ? (
|
{resultsError ? (
|
||||||
<div className={styles.autoImportEmpty}>
|
<Notice tone="danger" role="alert">
|
||||||
Failed to load imports: {getErrorMessage(resultsQuery.error)}
|
Failed to load imports: {resultsError}
|
||||||
</div>
|
</Notice>
|
||||||
) : allResults.length === 0 ? (
|
) : allResults.length === 0 ? (
|
||||||
<div className={styles.autoImportEmpty}>
|
<div className={styles.autoImportEmpty}>
|
||||||
<p>No imports yet. Drop album folders or single tracks into your import folder.</p>
|
<p>No imports yet. Drop album folders or single tracks into your import folder.</p>
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
|
|
||||||
import type { ImportQueueJob } from '../-import.types';
|
import type { ImportQueueJob, ImportStagingFile } from '../-import.types';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
importStagingFilesQueryOptions,
|
importStagingFilesQueryOptions,
|
||||||
|
|
@ -11,6 +11,8 @@ import {
|
||||||
import { getTrackDisplayInfo, IMPORT_PLACEHOLDER_IMAGE } from '../-import.helpers';
|
import { getTrackDisplayInfo, IMPORT_PLACEHOLDER_IMAGE } from '../-import.helpers';
|
||||||
import { useImportQueueWorkflow, useImportWorkflowStore } from '../-import.store';
|
import { useImportQueueWorkflow, useImportWorkflowStore } from '../-import.store';
|
||||||
|
|
||||||
|
const EMPTY_STAGING_FILES: ImportStagingFile[] = [];
|
||||||
|
|
||||||
export function useImportStaging() {
|
export function useImportStaging() {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const clearFinishedJobs = useImportWorkflowStore((state) => state.clearFinishedJobs);
|
const clearFinishedJobs = useImportWorkflowStore((state) => state.clearFinishedJobs);
|
||||||
|
|
@ -23,7 +25,8 @@ export function useImportStaging() {
|
||||||
clearFinishedJobs();
|
clearFinishedJobs();
|
||||||
await invalidateImportStagingQueries(queryClient);
|
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',
|
stagingPath: stagingQuery.data?.staging_path || 'Not configured',
|
||||||
stagingQuery,
|
stagingQuery,
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -13,8 +13,10 @@ export const Route = createFileRoute('/import')({
|
||||||
throw redirect({ href: getProfileHomePath(bridge), replace: true });
|
throw redirect({ href: getProfileHomePath(bridge), replace: true });
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
loader: async ({ context }) => {
|
loader: ({ context }) => {
|
||||||
await context.queryClient.ensureQueryData(importStagingFilesQueryOptions());
|
// 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,
|
component: ImportPage,
|
||||||
});
|
});
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue