fix(web): Doppelausloesung des Loeschknopfs sperren, Fahrzeugtabelle uebersetzt

VehicleTable.confirmDelete liess das Beschaeftigt-Kennzeichen zwar setzen,
aber nie lesen (const [, setIsDeleting]); Dialogschaltflaechen blieben
waehrend der laufenden Loeschanfrage bedienbar. Kennzeichen jetzt lesbar
gebunden, Dialog reicht den Zustand an Bestaetigen/Abbrechen weiter
(disabled + Sperr-Klassen), und confirmDelete bricht bei bereits laufender
Loeschung selbst ab.

Alle sieben fest verdrahteten Texte und sechs Vorlesehilfen der Tabelle
jetzt ueber next-intl (sieben neue Schluessel im Bereich dkvFleet, gleicher
Schluesselsatz in de.json und en.json).

Nebenbefund beim Testen: `load` haette mit `t` als Abhaengigkeit bei einem
instabilen Uebersetzer-Mock einen Abruf-bei-jedem-Render-Zyklus ausgeloest
— bewusst mit leerem Abhaengigkeitsfeld gelassen. Ausserdem
`vi.restoreAllMocks()` im Testabbau ersetzt: es leerte die Aufrufzaehlung
der reinen vi.fn()-Mocks nicht, wodurch der neue Doppelklick-Test falsche
Aufrufzahlen sah.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TPPB4ApQxzSU1rwV2Ffj9J
This commit is contained in:
2026-09-21 11:39:03 +02:00
parent f7c02b7fc9
commit e56cce4bf3
4 changed files with 135 additions and 24 deletions
@@ -22,6 +22,7 @@ vi.mock('next-intl', () => ({
'form.cancelDialog': 'Abbrechen', 'form.cancelDialog': 'Abbrechen',
'form.save': 'Speichern', 'form.save': 'Speichern',
'form.cancel': 'Bearbeitung abbrechen', 'form.cancel': 'Bearbeitung abbrechen',
'form.saveRow': 'Änderungen speichern',
}, },
}; };
// Support dot-notation keys // Support dot-notation keys
@@ -47,7 +48,14 @@ vi.mock('@/lib/dkv-api', () => ({
afterEach(() => { afterEach(() => {
cleanup(); cleanup();
vi.restoreAllMocks(); // vi.restoreAllMocks() does not reset the call history of plain vi.fn()
// mocks (no "original" implementation to restore to) — mockDeleteVehicle's
// call count was leaking across tests, which the new double-click test
// below (toHaveBeenCalledTimes) exposed. vi.clearAllMocks() clears
// mock.calls/mock.results on every mock; each test sets its own
// mockResolvedValue*/mockImplementation before rendering, so leftover
// implementations from the previous test are harmless.
vi.clearAllMocks();
}); });
describe('VehicleTable', () => { describe('VehicleTable', () => {
@@ -113,8 +121,9 @@ describe('VehicleTable', () => {
expect(screen.getByText(/wird gelöscht/i)).toBeInTheDocument(); expect(screen.getByText(/wird gelöscht/i)).toBeInTheDocument();
}); });
// Confirm the deletion // Confirm the deletion — button label comes from the (unmapped) key
const confirmBtn = screen.getByRole('button', { name: 'Löschen' }); // 'form.deleteConfirm', proving the text is no longer hardcoded.
const confirmBtn = screen.getByRole('button', { name: 'form.deleteConfirm' });
fireEvent.click(confirmBtn); fireEvent.click(confirmBtn);
await waitFor(() => { await waitFor(() => {
@@ -202,4 +211,86 @@ describe('VehicleTable', () => {
expect(screen.getByRole('button', { name: 'Bearbeitung abbrechen' })).toBeInTheDocument(); expect(screen.getByRole('button', { name: 'Bearbeitung abbrechen' })).toBeInTheDocument();
}); });
}); });
it('double-clicking the delete confirm button triggers exactly one deleteVehicle call', async () => {
mockFetchVehicles.mockResolvedValue([
{ id: 'v1', kennzeichen: 'M-AB 123', marke: 'VW', modell: 'Golf', fahrer: 'Hans Müller' },
]);
// Deletion promise resolution is under the test's own control, so the
// in-flight state is actually observable between the two clicks.
let resolveDelete: () => void = () => {};
mockDeleteVehicle.mockImplementation(
() => new Promise<void>((resolve) => { resolveDelete = resolve; }),
);
const { VehicleTable } = await import(
'@/app/(portal)/modules/dkv-fleet/settings/components/VehicleTable'
);
render(<VehicleTable />);
await waitFor(() => {
expect(screen.getByText('M-AB 123')).toBeInTheDocument();
});
fireEvent.click(screen.getByRole('button', { name: 'Fahrzeug löschen' }));
await waitFor(() => {
expect(screen.getByText(/wird gelöscht/i)).toBeInTheDocument();
});
const confirmBtn = screen.getByRole('button', { name: 'form.deleteConfirm' });
fireEvent.click(confirmBtn);
fireEvent.click(confirmBtn);
await waitFor(() => {
expect(mockDeleteVehicle).toHaveBeenCalledTimes(1);
});
resolveDelete();
});
it('confirm and cancel buttons of the delete dialog are disabled while the deletion is in flight', async () => {
mockFetchVehicles.mockResolvedValue([
{ id: 'v1', kennzeichen: 'M-AB 123', marke: 'VW', modell: 'Golf', fahrer: 'Hans Müller' },
]);
let resolveDelete: () => void = () => {};
mockDeleteVehicle.mockImplementation(
() => new Promise<void>((resolve) => { resolveDelete = resolve; }),
);
const { VehicleTable } = await import(
'@/app/(portal)/modules/dkv-fleet/settings/components/VehicleTable'
);
render(<VehicleTable />);
await waitFor(() => {
expect(screen.getByText('M-AB 123')).toBeInTheDocument();
});
fireEvent.click(screen.getByRole('button', { name: 'Fahrzeug löschen' }));
const confirmBtn = await screen.findByRole('button', { name: 'form.deleteConfirm' });
const cancelBtn = screen.getByRole('button', { name: 'Abbrechen' });
fireEvent.click(confirmBtn);
await waitFor(() => {
expect(confirmBtn).toBeDisabled();
expect(cancelBtn).toBeDisabled();
});
resolveDelete();
});
it('shows the load-error key when fetchVehicles fails', async () => {
mockFetchVehicles.mockRejectedValue(new Error('network down'));
const { VehicleTable } = await import(
'@/app/(portal)/modules/dkv-fleet/settings/components/VehicleTable'
);
render(<VehicleTable />);
await waitFor(() => {
expect(screen.getByText('errors.loadVehiclesFailed')).toBeInTheDocument();
});
});
}); });
@@ -86,11 +86,12 @@ const editInputCls =
// --- Delete confirm dialog --- // --- Delete confirm dialog ---
interface DeleteDialogProps { interface DeleteDialogProps {
plate: string; plate: string;
isDeleting: boolean;
onConfirm: () => void; onConfirm: () => void;
onCancel: () => void; onCancel: () => void;
} }
function DeleteDialog({ plate, onConfirm, onCancel }: DeleteDialogProps) { function DeleteDialog({ plate, isDeleting, onConfirm, onCancel }: DeleteDialogProps) {
const t = useTranslations('dkvFleet'); const t = useTranslations('dkvFleet');
return ( return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40"> <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40">
@@ -105,16 +106,18 @@ function DeleteDialog({ plate, onConfirm, onCancel }: DeleteDialogProps) {
<button <button
type="button" type="button"
onClick={onCancel} onClick={onCancel}
className="rounded border border-border px-4 py-2 text-sm text-foreground hover:bg-muted" disabled={isDeleting}
className="rounded border border-border px-4 py-2 text-sm text-foreground hover:bg-muted disabled:opacity-50 disabled:cursor-not-allowed"
> >
{t('form.cancelDialog')} {t('form.cancelDialog')}
</button> </button>
<button <button
type="button" type="button"
onClick={onConfirm} onClick={onConfirm}
className="rounded bg-destructive px-4 py-2 text-sm font-medium text-destructive-foreground hover:bg-destructive/90" disabled={isDeleting}
className="rounded bg-destructive px-4 py-2 text-sm font-medium text-destructive-foreground hover:bg-destructive/90 disabled:opacity-50 disabled:cursor-not-allowed"
> >
Löschen {t('form.deleteConfirm')}
</button> </button>
</div> </div>
</div> </div>
@@ -161,7 +164,7 @@ export function VehicleTable() {
// Delete confirm // Delete confirm
const [deleteTarget, setDeleteTarget] = useState<DkvVehicle | null>(null); const [deleteTarget, setDeleteTarget] = useState<DkvVehicle | null>(null);
const [, setIsDeleting] = useState(false); const [isDeleting, setIsDeleting] = useState(false);
// Server error below table // Server error below table
const [tableError, setTableError] = useState<string | null>(null); const [tableError, setTableError] = useState<string | null>(null);
@@ -181,11 +184,13 @@ export function VehicleTable() {
const data = await fetchVehicles(); const data = await fetchVehicles();
setVehicles(data); setVehicles(data);
} catch { } catch {
setTableError('Fahrzeuge konnten nicht geladen werden.'); setTableError(t('errors.loadVehiclesFailed'));
} finally { } finally {
setIsLoading(false); setIsLoading(false);
} }
}, []); }, []); // `t` intentionally excluded: `load` is a dependency of the
// mount-time useEffect below, and an unstable `t` identity would turn
// that into a fetch-on-every-render loop instead of fetch-once-on-mount.
useEffect(() => { useEffect(() => {
load(); load();
@@ -207,7 +212,7 @@ export function VehicleTable() {
const saveEdit = async (id: string) => { const saveEdit = async (id: string) => {
if (!editState.kennzeichen.trim()) { if (!editState.kennzeichen.trim()) {
setEditError('Kennzeichen ist erforderlich.'); setEditError(t('errors.licensePlateRequired'));
return; return;
} }
setIsSavingRow(true); setIsSavingRow(true);
@@ -223,7 +228,7 @@ export function VehicleTable() {
setEditId(null); setEditId(null);
await load(); await load();
} catch { } catch {
setEditError('Speichern fehlgeschlagen.'); setEditError(t('errors.saveVehicleFailed'));
} finally { } finally {
setIsSavingRow(false); setIsSavingRow(false);
} }
@@ -245,7 +250,7 @@ export function VehicleTable() {
const saveNew = async () => { const saveNew = async () => {
if (!newRow.kennzeichen.trim()) { if (!newRow.kennzeichen.trim()) {
setNewRowError('Kennzeichen ist erforderlich.'); setNewRowError(t('errors.licensePlateRequired'));
return; return;
} }
setIsSavingNew(true); setIsSavingNew(true);
@@ -262,7 +267,7 @@ export function VehicleTable() {
setNewRow(EMPTY_EDIT); setNewRow(EMPTY_EDIT);
await load(); await load();
} catch { } catch {
setNewRowError('Fahrzeug konnte nicht hinzugefügt werden.'); setNewRowError(t('errors.createVehicleFailed'));
} finally { } finally {
setIsSavingNew(false); setIsSavingNew(false);
} }
@@ -270,14 +275,14 @@ export function VehicleTable() {
// --- Delete --- // --- Delete ---
const confirmDelete = async () => { const confirmDelete = async () => {
if (!deleteTarget) return; if (!deleteTarget || isDeleting) return;
setIsDeleting(true); setIsDeleting(true);
try { try {
await deleteVehicle(deleteTarget.id); await deleteVehicle(deleteTarget.id);
setDeleteTarget(null); setDeleteTarget(null);
await load(); await load();
} catch { } catch {
setTableError('Fahrzeug konnte nicht gelöscht werden.'); setTableError(t('errors.deleteVehicleFailed'));
setDeleteTarget(null); setDeleteTarget(null);
} finally { } finally {
setIsDeleting(false); setIsDeleting(false);
@@ -394,7 +399,7 @@ export function VehicleTable() {
<div className="flex gap-1"> <div className="flex gap-1">
<button <button
type="button" type="button"
aria-label="Änderungen speichern" aria-label={t('form.saveRow')}
onClick={() => saveEdit(v.id)} onClick={() => saveEdit(v.id)}
disabled={isSavingRow} disabled={isSavingRow}
className={`${iconBtnCls} hover:text-primary`} className={`${iconBtnCls} hover:text-primary`}
@@ -403,7 +408,7 @@ export function VehicleTable() {
</button> </button>
<button <button
type="button" type="button"
aria-label="Bearbeitung abbrechen" aria-label={t('form.cancel')}
onClick={cancelEdit} onClick={cancelEdit}
className={iconBtnCls} className={iconBtnCls}
> >
@@ -429,7 +434,7 @@ export function VehicleTable() {
<div className="flex gap-1"> <div className="flex gap-1">
<button <button
type="button" type="button"
aria-label="Fahrzeug bearbeiten" aria-label={t('editVehicle')}
onClick={() => startEdit(v)} onClick={() => startEdit(v)}
className={iconBtnCls} className={iconBtnCls}
> >
@@ -437,7 +442,7 @@ export function VehicleTable() {
</button> </button>
<button <button
type="button" type="button"
aria-label="Fahrzeug löschen" aria-label={t('deleteVehicle')}
onClick={() => setDeleteTarget(v)} onClick={() => setDeleteTarget(v)}
className={`${iconBtnCls} hover:text-destructive`} className={`${iconBtnCls} hover:text-destructive`}
> >
@@ -501,7 +506,7 @@ export function VehicleTable() {
<div className="flex gap-1"> <div className="flex gap-1">
<button <button
type="button" type="button"
aria-label="Änderungen speichern" aria-label={t('form.saveRow')}
onClick={saveNew} onClick={saveNew}
disabled={isSavingNew} disabled={isSavingNew}
className={`${iconBtnCls} hover:text-primary`} className={`${iconBtnCls} hover:text-primary`}
@@ -510,7 +515,7 @@ export function VehicleTable() {
</button> </button>
<button <button
type="button" type="button"
aria-label="Bearbeitung abbrechen" aria-label={t('form.cancel')}
onClick={cancelAdd} onClick={cancelAdd}
className={iconBtnCls} className={iconBtnCls}
> >
@@ -536,6 +541,7 @@ export function VehicleTable() {
{deleteTarget && ( {deleteTarget && (
<DeleteDialog <DeleteDialog
plate={deleteTarget.kennzeichen} plate={deleteTarget.kennzeichen}
isDeleting={isDeleting}
onConfirm={confirmDelete} onConfirm={confirmDelete}
onCancel={() => setDeleteTarget(null)} onCancel={() => setDeleteTarget(null)}
/> />
+8 -1
View File
@@ -670,6 +670,8 @@
"vehicles": "Fahrzeuge" "vehicles": "Fahrzeuge"
}, },
"form": { "form": {
"saveRow": "Änderungen speichern",
"deleteConfirm": "Löschen",
"protocol": "Protokoll", "protocol": "Protokoll",
"host": "Host", "host": "Host",
"hostExchange": "EWS-URL", "hostExchange": "EWS-URL",
@@ -706,7 +708,12 @@
}, },
"errors": { "errors": {
"pollFailed": "Posteingang konnte nicht geprüft werden. Überprüfe die Konfiguration unter 'Moduleinstellungen > Posteingang'.", "pollFailed": "Posteingang konnte nicht geprüft werden. Überprüfe die Konfiguration unter 'Moduleinstellungen > Posteingang'.",
"smtpFailed": "Export wurde erstellt, konnte aber nicht gesendet werden. Die Datei ist weiterhin zum Download verfügbar." "smtpFailed": "Export wurde erstellt, konnte aber nicht gesendet werden. Die Datei ist weiterhin zum Download verfügbar.",
"licensePlateRequired": "Kennzeichen ist erforderlich.",
"loadVehiclesFailed": "Fahrzeuge konnten nicht geladen werden.",
"saveVehicleFailed": "Speichern fehlgeschlagen.",
"createVehicleFailed": "Fahrzeug konnte nicht hinzugefügt werden.",
"deleteVehicleFailed": "Fahrzeug konnte nicht gelöscht werden."
} }
}, },
"marketplace": { "marketplace": {
+8 -1
View File
@@ -670,6 +670,8 @@
"vehicles": "Vehicles" "vehicles": "Vehicles"
}, },
"form": { "form": {
"saveRow": "Save changes",
"deleteConfirm": "Delete",
"protocol": "Protocol", "protocol": "Protocol",
"host": "Host", "host": "Host",
"hostExchange": "EWS URL", "hostExchange": "EWS URL",
@@ -706,7 +708,12 @@
}, },
"errors": { "errors": {
"pollFailed": "Could not check inbox. Review the configuration under 'Module Settings > Inbox'.", "pollFailed": "Could not check inbox. Review the configuration under 'Module Settings > Inbox'.",
"smtpFailed": "Export was created but could not be sent. The file is still available for manual download." "smtpFailed": "Export was created but could not be sent. The file is still available for manual download.",
"licensePlateRequired": "License plate is required.",
"loadVehiclesFailed": "Vehicles could not be loaded.",
"saveVehicleFailed": "Saving failed.",
"createVehicleFailed": "Vehicle could not be added.",
"deleteVehicleFailed": "Vehicle could not be deleted."
} }
}, },
"marketplace": { "marketplace": {