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.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)}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -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": {
|
||||||
|
|||||||
@@ -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": {
|
||||||
|
|||||||
Reference in New Issue
Block a user