openreader/src/components/documents/DexieMigrationModal.tsx
Richard R 7d9d8de967 test(ui): stabilize onboarding/modal dismissal in e2e helpers
Refactor test helpers to reliably dismiss onboarding and migration modals during
UI flows. Replace ad-hoc Settings-only dismissal with a unified dismissOnboardingModals
routine that targets privacy, migration, and settings dialogs via testids,
interacts with visible controls (checkbox, continue/skip/save buttons) and waits
for dialogs to hide. Increase resilience by repeating checks, adding short
settle delays, and tightening timeouts.

Also add data-testid attributes to PrivacyModal, SettingsModal and
DexieMigrationModal to make the selectors deterministic and reduce flakiness in
export/upload tests.

No production behavior changes.
2026-04-06 11:16:54 -06:00

255 lines
11 KiB
TypeScript

'use client';
import { Fragment, useCallback, useEffect, useRef, useState } from 'react';
import { Dialog, DialogPanel, DialogTitle, Transition, TransitionChild, Button } from '@headlessui/react';
import { getAppConfig, getAllEpubDocuments, getAllHtmlDocuments, getAllPdfDocuments, updateAppConfig } from '@/lib/client/dexie';
import { listDocuments, mimeTypeForDoc, uploadDocuments } from '@/lib/client/api/documents';
import { useDocuments } from '@/contexts/DocumentContext';
import type { BaseDocument } from '@/types/documents';
import { cacheStoredDocumentFromBytes } from '@/lib/client/cache/documents';
export function DexieMigrationModal() {
const { refreshDocuments } = useDocuments();
const [isOpen, setIsOpen] = useState(false);
const [localCount, setLocalCount] = useState(0);
const [missingCount, setMissingCount] = useState(0);
const [isUploading, setIsUploading] = useState(false);
const [progress, setProgress] = useState(0);
const [status, setStatus] = useState<string>('');
const checkedRef = useRef(false);
const closeDisabled = isUploading;
const loadLocalDexieDocs = useCallback(async (): Promise<{
docs: BaseDocument[];
pdfById: Map<string, { id: string; name: string; size: number; lastModified: number; data: ArrayBuffer }>;
epubById: Map<string, { id: string; name: string; size: number; lastModified: number; data: ArrayBuffer }>;
htmlById: Map<string, { id: string; name: string; size: number; lastModified: number; data: string }>;
}> => {
const [pdfs, epubs, htmls] = await Promise.all([getAllPdfDocuments(), getAllEpubDocuments(), getAllHtmlDocuments()]);
const docs: BaseDocument[] = [
...pdfs.map((d) => ({ id: d.id, name: d.name, size: d.size, lastModified: d.lastModified, type: 'pdf' as const })),
...epubs.map((d) => ({ id: d.id, name: d.name, size: d.size, lastModified: d.lastModified, type: 'epub' as const })),
...htmls.map((d) => ({ id: d.id, name: d.name, size: d.size, lastModified: d.lastModified, type: 'html' as const })),
];
const pdfById = new Map(pdfs.map((d) => [d.id, d] as const));
const epubById = new Map(epubs.map((d) => [d.id, d] as const));
const htmlById = new Map(htmls.map((d) => [d.id, d] as const));
return { docs, pdfById, epubById, htmlById };
}, []);
const checkAndMaybePrompt = useCallback(async () => {
if (checkedRef.current) return;
checkedRef.current = true;
const cfg = await getAppConfig();
if (!cfg?.privacyAccepted) {
// Wait for privacy acceptance before prompting.
checkedRef.current = false;
return;
}
if (cfg.documentsMigrationPrompted) return;
const { docs } = await loadLocalDexieDocs();
const count = docs.length;
setLocalCount(count);
if (count === 0) return;
const serverDocs = await listDocuments().catch(() => null);
if (serverDocs) {
const serverIds = new Set(serverDocs.map((d) => d.id));
const missing = docs.filter((d) => !serverIds.has(d.id));
setMissingCount(missing.length);
if (missing.length === 0) return;
} else {
// If the server list fails, still prompt so the user can attempt upload.
setMissingCount(count);
}
setIsOpen(true);
}, [loadLocalDexieDocs]);
useEffect(() => {
checkAndMaybePrompt().catch((err) => {
console.error('Dexie migration check failed:', err);
});
}, [checkAndMaybePrompt]);
useEffect(() => {
const handler = () => {
checkedRef.current = false;
checkAndMaybePrompt().catch((err) => console.error('Dexie migration check failed:', err));
};
window.addEventListener('openreader:privacyAccepted', handler as EventListener);
return () => window.removeEventListener('openreader:privacyAccepted', handler as EventListener);
}, [checkAndMaybePrompt]);
const title = 'Upload your local documents?';
const handleSkip = useCallback(async () => {
await updateAppConfig({ documentsMigrationPrompted: true });
setIsOpen(false);
}, []);
const handleUpload = useCallback(async () => {
setIsUploading(true);
setProgress(0);
setStatus('Preparing upload...');
try {
const { docs, pdfById, epubById, htmlById } = await loadLocalDexieDocs();
const serverDocs = await listDocuments().catch(() => null);
const serverIds = serverDocs ? new Set(serverDocs.map((d) => d.id)) : null;
const toUpload = serverIds ? docs.filter((d) => !serverIds.has(d.id)) : docs;
setMissingCount(toUpload.length);
const encoder = new TextEncoder();
for (let i = 0; i < toUpload.length; i++) {
const doc = toUpload[i];
setStatus(`Uploading ${i + 1}/${toUpload.length}: ${doc.name}`);
setProgress((i / Math.max(1, toUpload.length)) * 100);
// Pull raw data from Dexie for this doc
if (doc.type === 'pdf') {
const full = pdfById.get(doc.id) ?? null;
if (!full) continue;
const bytes = full.data.slice(0);
const file = new File([full.data], full.name, {
type: mimeTypeForDoc(doc),
lastModified: full.lastModified,
});
const uploaded = await uploadDocuments([file]);
const stored = uploaded[0] ?? null;
if (stored) {
await cacheStoredDocumentFromBytes(stored, bytes).catch(() => { });
}
} else if (doc.type === 'epub') {
const full = epubById.get(doc.id) ?? null;
if (!full) continue;
const bytes = full.data.slice(0);
const file = new File([full.data], full.name, {
type: mimeTypeForDoc(doc),
lastModified: full.lastModified,
});
const uploaded = await uploadDocuments([file]);
const stored = uploaded[0] ?? null;
if (stored) {
await cacheStoredDocumentFromBytes(stored, bytes).catch(() => { });
}
} else {
const full = htmlById.get(doc.id) ?? null;
if (!full) continue;
const bytes = encoder.encode(full.data).buffer;
const file = new File([full.data], full.name, {
type: mimeTypeForDoc(doc),
lastModified: full.lastModified,
});
const uploaded = await uploadDocuments([file]);
const stored = uploaded[0] ?? null;
if (stored) {
await cacheStoredDocumentFromBytes(stored, bytes).catch(() => { });
}
}
}
setProgress(100);
setStatus('Refreshing...');
await refreshDocuments();
await updateAppConfig({ documentsMigrationPrompted: true });
setIsOpen(false);
} catch (err) {
console.error('Dexie migration upload failed:', err);
setStatus('Upload failed. You can retry or skip.');
checkedRef.current = false;
} finally {
setIsUploading(false);
}
}, [loadLocalDexieDocs, refreshDocuments]);
if (!isOpen) return null;
return (
<Transition appear show={isOpen} as={Fragment}>
<Dialog as="div" className="relative z-[70]" onClose={() => (closeDisabled ? null : setIsOpen(false))}>
<TransitionChild
as={Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="ease-in duration-200"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<div className="fixed inset-0 overlay-dim backdrop-blur-sm" />
</TransitionChild>
<div className="fixed inset-0 overflow-y-auto">
<div className="flex min-h-full items-start justify-center p-4 pt-6 text-center sm:items-center sm:pt-4">
<TransitionChild
as={Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0 scale-95"
enterTo="opacity-100 scale-100"
leave="ease-in duration-200"
leaveFrom="opacity-100 scale-100"
leaveTo="opacity-0 scale-95"
>
<DialogPanel data-testid="migration-modal" className="w-full max-w-md transform rounded-2xl bg-base p-6 text-left align-middle shadow-xl transition-all">
<DialogTitle as="h3" className="text-lg font-semibold leading-6 text-foreground mb-4">
{title}
</DialogTitle>
<div className="space-y-2">
<p className="text-sm text-muted mb-2">
Found {localCount} document{localCount === 1 ? '' : 's'} stored locally from an older version.
{missingCount > 0 ? (
<> {missingCount} {missingCount === 1 ? 'is' : 'are'} not here yet.</>
) : null}
{' '}This app now stores documents on the server and keeps a local cache for speed.
</p>
{isUploading && (
<div className="space-y-1">
<p className="text-xs text-muted">{status}</p>
<div className="h-2 w-full rounded bg-offbase">
<div className="h-2 rounded bg-accent" style={{ width: `${Math.max(1, Math.round(progress))}%` }} />
</div>
</div>
)}
{!isUploading && status ? <p className="text-xs text-red-500">{status}</p> : null}
</div>
<div className="flex justify-end gap-3 mt-6">
<Button
data-testid="migration-skip-button"
onClick={handleSkip}
disabled={isUploading}
className="inline-flex justify-center rounded-lg bg-background px-3 py-1.5 text-sm
font-medium text-foreground hover:bg-offbase focus:outline-none
focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2
transform transition-transform duration-200 ease-in-out hover:scale-[1.04] hover:text-accent
disabled:opacity-50"
>
Skip
</Button>
<Button
onClick={handleUpload}
disabled={isUploading}
className="inline-flex justify-center rounded-lg bg-accent px-3 py-1.5 text-sm
font-medium text-background hover:bg-secondary-accent focus:outline-none
focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2
transform transition-transform duration-200 ease-in-out hover:scale-[1.04] hover:text-background
disabled:opacity-50"
>
{isUploading ? 'Uploading…' : 'Upload'}
</Button>
</div>
</DialogPanel>
</TransitionChild>
</div>
</div>
</Dialog>
</Transition>
);
}