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 { 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(<AppRouterProvider router={router} queryClient={queryClient} />),
|
||||
};
|
||||
}
|
||||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<div className={styles.autoImportEmpty}>
|
||||
Auto-import is unavailable: {getErrorMessage(statusQuery.error)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
const statusError =
|
||||
statusQuery.error && !statusQuery.data ? getErrorMessage(statusQuery.error) : '';
|
||||
const resultsError =
|
||||
resultsQuery.error && !resultsQuery.data ? getErrorMessage(resultsQuery.error) : '';
|
||||
|
||||
return (
|
||||
<>
|
||||
{statusError ? (
|
||||
<Notice tone="danger" role="alert">
|
||||
Auto-import is unavailable: {statusError}
|
||||
</Notice>
|
||||
) : null}
|
||||
<div className={styles.autoImportControls}>
|
||||
<div className={styles.autoImportToggleRow}>
|
||||
<div className={styles.autoImportToggleLabel}>
|
||||
|
|
@ -293,10 +294,10 @@ export function AutoImportPanel({
|
|||
) : null}
|
||||
|
||||
<div className={styles.autoImportResults} id="auto-import-results">
|
||||
{resultsQuery.error ? (
|
||||
<div className={styles.autoImportEmpty}>
|
||||
Failed to load imports: {getErrorMessage(resultsQuery.error)}
|
||||
</div>
|
||||
{resultsError ? (
|
||||
<Notice tone="danger" role="alert">
|
||||
Failed to load imports: {resultsError}
|
||||
</Notice>
|
||||
) : allResults.length === 0 ? (
|
||||
<div className={styles.autoImportEmpty}>
|
||||
<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 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,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue