Next.js/React/TypeScript auf web/shl (SHL-01), Next.js/Test-Aufbau nach
dem Muster von web/pdf-viewer (DOC-05). lib/chunkUpload.ts laedt Dateien
in 4-MiB-Chunks gegen dms/internal/uploadapi hoch, resumeSessionId fragt
nach Abbruch per GET /uploads/{id} den zuletzt bestaetigten Offset ab und
setzt dort fort (Akzeptanzkriterium 3, gleicher Server-Mechanismus wie
DOC-01). lib/uploadQueue.ts ist reine, DOM-unabhaengige Reducer-Logik fuer
Status/Fortschritt je Datei (Akzeptanzkriterien 1+2) - Ids werden VOR dem
Dispatch erzeugt (createQueuedItem), nicht im Reducer selbst: ein frueher
Versuch mit einer Id-Factory-Funktion im Reducer scheiterte real beim
Testen (useReducer ruft den Reducer erst in der Render-Phase auf, Code
direkt nach dispatch() sah noch ein leeres Ids-Array).
components/Dropzone.tsx nutzt ein natives <label for>+<input type=file
multiple> fuer Tastatur-/Dateidialog-Bedienung statt eigenem
Tastatur-Handler.
14/14 Vitest-Tests (uploadQueue, chunkUpload, Dropzone, UploadManager),
mindestens ein Test je Akzeptanzkriterium. npx tsc --noEmit sauber,
npm run build erfolgreich.
Pruefungen vor der Abnahme (Details siehe DOC-03-PRUEFPROTOKOLL.md):
1. Tastatur/Dateidialog bedienbar - bestanden (Dropzone.test.tsx)
2. Netzabbruch im Browser - real gegen echten HTTP-Mock-Server + echten
Chromium (Playwright) getestet, echter net::ERR_CONNECTION_RESET,
Retry fuehrt zum Abschluss
3. Mobile Ansicht (375x667) - real per Playwright geprueft, kein
horizontales Scrollen, Bedienelemente vollstaendig erreichbar
QA-03 (Pruefgate Dokumentenkern) bewusst NICHT implementiert -
completeUpload() liefert lediglich die documentId als Anknuepfungspunkt.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
90 lines
3.3 KiB
TypeScript
90 lines
3.3 KiB
TypeScript
// Reine, DOM-unabhaengige Zustandslogik fuer die Upload-Warteschlange
|
|
// (Akzeptanzkriterien 1-3: mehrere Dateien, Fortschritt/Status je Datei,
|
|
// einzeln wiederholbar) — analog lib/viewerState.ts aus DOC-05.
|
|
|
|
export type UploadItemStatus = "queued" | "uploading" | "success" | "error";
|
|
|
|
export interface UploadItem {
|
|
id: string;
|
|
file: File;
|
|
status: UploadItemStatus;
|
|
bytesUploaded: number;
|
|
errorMessage?: string;
|
|
documentId?: string;
|
|
sessionId?: string;
|
|
}
|
|
|
|
export interface UploadQueueState {
|
|
items: UploadItem[];
|
|
}
|
|
|
|
export function initialQueueState(): UploadQueueState {
|
|
return { items: [] };
|
|
}
|
|
|
|
export type UploadQueueAction =
|
|
| { type: "filesAdded"; items: UploadItem[] }
|
|
| { type: "uploadStarted"; id: string }
|
|
| { type: "sessionAssigned"; id: string; sessionId: string }
|
|
| { type: "progress"; id: string; bytesUploaded: number }
|
|
| { type: "succeeded"; id: string; documentId: string }
|
|
| { type: "failed"; id: string; message: string }
|
|
| { type: "retry"; id: string }
|
|
| { type: "removed"; id: string };
|
|
|
|
let counter = 0;
|
|
|
|
// Ids werden VOR dem Dispatch erzeugt (createQueuedItem), nicht innerhalb
|
|
// des Reducers — useReducer ruft den Reducer erst waehrend der naechsten
|
|
// Render-Phase auf, ein Nebeneffekt (ID-Zaehler) dort waere zu spaet fuer
|
|
// Code, das sich direkt im Anschluss an dispatch() auf die neuen IDs
|
|
// verlaesst (siehe UploadManager.handleFilesSelected).
|
|
export function createQueuedItem(file: File): UploadItem {
|
|
counter += 1;
|
|
return {
|
|
id: `upload-${Date.now()}-${counter}-${Math.random().toString(36).slice(2, 8)}`,
|
|
file,
|
|
status: "queued",
|
|
bytesUploaded: 0,
|
|
};
|
|
}
|
|
|
|
export function applyUploadQueueAction(state: UploadQueueState, action: UploadQueueAction): UploadQueueState {
|
|
switch (action.type) {
|
|
case "filesAdded":
|
|
return { items: [...state.items, ...action.items] };
|
|
case "uploadStarted":
|
|
return { items: mapItem(state, action.id, (item) => ({ ...item, status: "uploading", errorMessage: undefined })) };
|
|
case "sessionAssigned":
|
|
return { items: mapItem(state, action.id, (item) => ({ ...item, sessionId: action.sessionId })) };
|
|
case "progress":
|
|
return { items: mapItem(state, action.id, (item) => ({ ...item, bytesUploaded: action.bytesUploaded })) };
|
|
case "succeeded":
|
|
return {
|
|
items: mapItem(state, action.id, (item) => ({
|
|
...item,
|
|
status: "success",
|
|
bytesUploaded: item.file.size,
|
|
documentId: action.documentId,
|
|
})),
|
|
};
|
|
case "failed":
|
|
return { items: mapItem(state, action.id, (item) => ({ ...item, status: "error", errorMessage: action.message })) };
|
|
case "retry":
|
|
return { items: mapItem(state, action.id, (item) => ({ ...item, status: "queued", errorMessage: undefined })) };
|
|
case "removed":
|
|
return { items: state.items.filter((item) => item.id !== action.id) };
|
|
default:
|
|
return state;
|
|
}
|
|
}
|
|
|
|
function mapItem(state: UploadQueueState, id: string, updater: (item: UploadItem) => UploadItem): UploadItem[] {
|
|
return state.items.map((item) => (item.id === id ? updater(item) : item));
|
|
}
|
|
|
|
export function progressPercent(item: UploadItem): number {
|
|
if (item.file.size === 0) return item.status === "success" ? 100 : 0;
|
|
return Math.min(100, Math.round((item.bytesUploaded / item.file.size) * 100));
|
|
}
|