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:
+94
-3
@@ -22,6 +22,7 @@ vi.mock('next-intl', () => ({
|
||||
'form.cancelDialog': 'Abbrechen',
|
||||
'form.save': 'Speichern',
|
||||
'form.cancel': 'Bearbeitung abbrechen',
|
||||
'form.saveRow': 'Änderungen speichern',
|
||||
},
|
||||
};
|
||||
// Support dot-notation keys
|
||||
@@ -47,7 +48,14 @@ vi.mock('@/lib/dkv-api', () => ({
|
||||
|
||||
afterEach(() => {
|
||||
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', () => {
|
||||
@@ -113,8 +121,9 @@ describe('VehicleTable', () => {
|
||||
expect(screen.getByText(/wird gelöscht/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Confirm the deletion
|
||||
const confirmBtn = screen.getByRole('button', { name: 'Löschen' });
|
||||
// Confirm the deletion — button label comes from the (unmapped) key
|
||||
// 'form.deleteConfirm', proving the text is no longer hardcoded.
|
||||
const confirmBtn = screen.getByRole('button', { name: 'form.deleteConfirm' });
|
||||
fireEvent.click(confirmBtn);
|
||||
|
||||
await waitFor(() => {
|
||||
@@ -202,4 +211,86 @@ describe('VehicleTable', () => {
|
||||
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 ---
|
||||
interface DeleteDialogProps {
|
||||
plate: string;
|
||||
isDeleting: boolean;
|
||||
onConfirm: () => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
function DeleteDialog({ plate, onConfirm, onCancel }: DeleteDialogProps) {
|
||||
function DeleteDialog({ plate, isDeleting, onConfirm, onCancel }: DeleteDialogProps) {
|
||||
const t = useTranslations('dkvFleet');
|
||||
return (
|
||||
<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
|
||||
type="button"
|
||||
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')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
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>
|
||||
</div>
|
||||
</div>
|
||||
@@ -161,7 +164,7 @@ export function VehicleTable() {
|
||||
|
||||
// Delete confirm
|
||||
const [deleteTarget, setDeleteTarget] = useState<DkvVehicle | null>(null);
|
||||
const [, setIsDeleting] = useState(false);
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
|
||||
// Server error below table
|
||||
const [tableError, setTableError] = useState<string | null>(null);
|
||||
@@ -181,11 +184,13 @@ export function VehicleTable() {
|
||||
const data = await fetchVehicles();
|
||||
setVehicles(data);
|
||||
} catch {
|
||||
setTableError('Fahrzeuge konnten nicht geladen werden.');
|
||||
setTableError(t('errors.loadVehiclesFailed'));
|
||||
} finally {
|
||||
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(() => {
|
||||
load();
|
||||
@@ -207,7 +212,7 @@ export function VehicleTable() {
|
||||
|
||||
const saveEdit = async (id: string) => {
|
||||
if (!editState.kennzeichen.trim()) {
|
||||
setEditError('Kennzeichen ist erforderlich.');
|
||||
setEditError(t('errors.licensePlateRequired'));
|
||||
return;
|
||||
}
|
||||
setIsSavingRow(true);
|
||||
@@ -223,7 +228,7 @@ export function VehicleTable() {
|
||||
setEditId(null);
|
||||
await load();
|
||||
} catch {
|
||||
setEditError('Speichern fehlgeschlagen.');
|
||||
setEditError(t('errors.saveVehicleFailed'));
|
||||
} finally {
|
||||
setIsSavingRow(false);
|
||||
}
|
||||
@@ -245,7 +250,7 @@ export function VehicleTable() {
|
||||
|
||||
const saveNew = async () => {
|
||||
if (!newRow.kennzeichen.trim()) {
|
||||
setNewRowError('Kennzeichen ist erforderlich.');
|
||||
setNewRowError(t('errors.licensePlateRequired'));
|
||||
return;
|
||||
}
|
||||
setIsSavingNew(true);
|
||||
@@ -262,7 +267,7 @@ export function VehicleTable() {
|
||||
setNewRow(EMPTY_EDIT);
|
||||
await load();
|
||||
} catch {
|
||||
setNewRowError('Fahrzeug konnte nicht hinzugefügt werden.');
|
||||
setNewRowError(t('errors.createVehicleFailed'));
|
||||
} finally {
|
||||
setIsSavingNew(false);
|
||||
}
|
||||
@@ -270,14 +275,14 @@ export function VehicleTable() {
|
||||
|
||||
// --- Delete ---
|
||||
const confirmDelete = async () => {
|
||||
if (!deleteTarget) return;
|
||||
if (!deleteTarget || isDeleting) return;
|
||||
setIsDeleting(true);
|
||||
try {
|
||||
await deleteVehicle(deleteTarget.id);
|
||||
setDeleteTarget(null);
|
||||
await load();
|
||||
} catch {
|
||||
setTableError('Fahrzeug konnte nicht gelöscht werden.');
|
||||
setTableError(t('errors.deleteVehicleFailed'));
|
||||
setDeleteTarget(null);
|
||||
} finally {
|
||||
setIsDeleting(false);
|
||||
@@ -394,7 +399,7 @@ export function VehicleTable() {
|
||||
<div className="flex gap-1">
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Änderungen speichern"
|
||||
aria-label={t('form.saveRow')}
|
||||
onClick={() => saveEdit(v.id)}
|
||||
disabled={isSavingRow}
|
||||
className={`${iconBtnCls} hover:text-primary`}
|
||||
@@ -403,7 +408,7 @@ export function VehicleTable() {
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Bearbeitung abbrechen"
|
||||
aria-label={t('form.cancel')}
|
||||
onClick={cancelEdit}
|
||||
className={iconBtnCls}
|
||||
>
|
||||
@@ -429,7 +434,7 @@ export function VehicleTable() {
|
||||
<div className="flex gap-1">
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Fahrzeug bearbeiten"
|
||||
aria-label={t('editVehicle')}
|
||||
onClick={() => startEdit(v)}
|
||||
className={iconBtnCls}
|
||||
>
|
||||
@@ -437,7 +442,7 @@ export function VehicleTable() {
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Fahrzeug löschen"
|
||||
aria-label={t('deleteVehicle')}
|
||||
onClick={() => setDeleteTarget(v)}
|
||||
className={`${iconBtnCls} hover:text-destructive`}
|
||||
>
|
||||
@@ -501,7 +506,7 @@ export function VehicleTable() {
|
||||
<div className="flex gap-1">
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Änderungen speichern"
|
||||
aria-label={t('form.saveRow')}
|
||||
onClick={saveNew}
|
||||
disabled={isSavingNew}
|
||||
className={`${iconBtnCls} hover:text-primary`}
|
||||
@@ -510,7 +515,7 @@ export function VehicleTable() {
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Bearbeitung abbrechen"
|
||||
aria-label={t('form.cancel')}
|
||||
onClick={cancelAdd}
|
||||
className={iconBtnCls}
|
||||
>
|
||||
@@ -536,6 +541,7 @@ export function VehicleTable() {
|
||||
{deleteTarget && (
|
||||
<DeleteDialog
|
||||
plate={deleteTarget.kennzeichen}
|
||||
isDeleting={isDeleting}
|
||||
onConfirm={confirmDelete}
|
||||
onCancel={() => setDeleteTarget(null)}
|
||||
/>
|
||||
|
||||
@@ -670,6 +670,8 @@
|
||||
"vehicles": "Fahrzeuge"
|
||||
},
|
||||
"form": {
|
||||
"saveRow": "Änderungen speichern",
|
||||
"deleteConfirm": "Löschen",
|
||||
"protocol": "Protokoll",
|
||||
"host": "Host",
|
||||
"hostExchange": "EWS-URL",
|
||||
@@ -706,7 +708,12 @@
|
||||
},
|
||||
"errors": {
|
||||
"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": {
|
||||
|
||||
@@ -670,6 +670,8 @@
|
||||
"vehicles": "Vehicles"
|
||||
},
|
||||
"form": {
|
||||
"saveRow": "Save changes",
|
||||
"deleteConfirm": "Delete",
|
||||
"protocol": "Protocol",
|
||||
"host": "Host",
|
||||
"hostExchange": "EWS URL",
|
||||
@@ -706,7 +708,12 @@
|
||||
},
|
||||
"errors": {
|
||||
"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": {
|
||||
|
||||
Reference in New Issue
Block a user