From ab68b39b1fe5a7a4199564b9c7661582b854d97d Mon Sep 17 00:00:00 2001 From: Florian Date: Fri, 30 Oct 2026 17:40:23 +0100 Subject: [PATCH] fix(crowd-reports): show a failed report submit where it can be seen The submit error rendered at the end of the dialog's scrolling form, below the location minimap. On a phone that is below the fold, so a failed submit looked like the Submit button did nothing. Render it between the form and the actions instead, show a spinner on Submit while the report is sent, and clear the error when the dialog is dismissed. --- integrations/crowd-reports/ReportDialog.tsx | 29 +++++-- .../__tests__/ReportDialog.test.tsx | 75 +++++++++++++++++++ 2 files changed, 99 insertions(+), 5 deletions(-) create mode 100644 integrations/crowd-reports/__tests__/ReportDialog.test.tsx diff --git a/integrations/crowd-reports/ReportDialog.tsx b/integrations/crowd-reports/ReportDialog.tsx index d757017c1..e0689a88e 100644 --- a/integrations/crowd-reports/ReportDialog.tsx +++ b/integrations/crowd-reports/ReportDialog.tsx @@ -19,6 +19,7 @@ import WarningIcon from "@mui/icons-material/Warning"; import Alert from "@mui/material/Alert"; import Box from "@mui/material/Box"; import Button from "@mui/material/Button"; +import CircularProgress from "@mui/material/CircularProgress"; import Dialog from "@mui/material/Dialog"; import DialogActions from "@mui/material/DialogActions"; import DialogContent from "@mui/material/DialogContent"; @@ -134,6 +135,12 @@ export function ReportDialog() { setSeverity(defaultSeverityForCategory(c)); }; + // A failed submit's error belongs to this report, not the next one. + const handleClose = () => { + submit.reset(); + closeDialog(); + }; + const handleSubmit = () => { if (!location) return; const claim = buildReportClaim({ @@ -154,7 +161,7 @@ export function ReportDialog() { return ( - - {submit.isError && {t("submitError")}} + {/* Outside the scrolling content: on a phone the form overflows, and an + error at its end would sit below the fold, so a failed submit would + look like nothing happened. */} + {submit.isError && ( + + {t("submitError")} + + )} - - + diff --git a/integrations/crowd-reports/__tests__/ReportDialog.test.tsx b/integrations/crowd-reports/__tests__/ReportDialog.test.tsx new file mode 100644 index 000000000..f5400529f --- /dev/null +++ b/integrations/crowd-reports/__tests__/ReportDialog.test.tsx @@ -0,0 +1,75 @@ +// @vitest-environment jsdom + +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { render, screen, userEvent } from "@/test"; +import { ReportDialog } from "../ReportDialog"; +import { useCrowdReportStore } from "../store"; + +vi.mock("next-intl", async () => (await import("@/test/intl")).mockNextIntl()); +vi.mock("@/integration-api/components/LocationMinimap", () => ({ + LocationMinimap: () => null, +})); +vi.mock("@/integration-api/runtime/useFullScreenOnMobile", () => ({ + useFullScreenOnMobile: () => true, + mobileFullScreenDialogPaperSx: {}, +})); +const { submitState } = vi.hoisted(() => ({ + submitState: { mutate: vi.fn(), reset: vi.fn(), isError: false, isPending: false }, +})); +vi.mock("../useCrowdReports", () => ({ useSubmitReport: () => submitState })); + +describe("ReportDialog", () => { + beforeEach(() => { + useCrowdReportStore.getState().openDialog([13.74, 51.05]); + }); + + afterEach(() => { + useCrowdReportStore.getState().closeDialog(); + Object.assign(submitState, { isError: false, isPending: false }); + submitState.mutate.mockReset(); + submitState.reset.mockReset(); + }); + + it("submits the selected category at the chosen location", async () => { + render(); + await userEvent.click(screen.getByRole("button", { name: /crowdReports\.category\.jam/ })); + await userEvent.click(screen.getByRole("button", { name: "crowdReports.submit" })); + + expect(submitState.mutate).toHaveBeenCalledWith( + expect.objectContaining({ + kind: "congestion", + geometry: { type: "Point", coordinates: [13.74, 51.05] }, + }), + expect.anything(), + ); + }); + + it("shows a failed submit beside the actions, outside the scrolling form", () => { + submitState.isError = true; + render(); + + const alert = screen.getByRole("alert"); + expect(alert).toHaveTextContent("crowdReports.submitError"); + // The form scrolls on a phone; an error at its end sits below the fold. + expect(alert.closest(".MuiDialogContent-root")).toBeNull(); + }); + + it("clears a failed submit when the dialog is cancelled", async () => { + submitState.isError = true; + render(); + await userEvent.click(screen.getByRole("button", { name: "crowdReports.cancel" })); + + expect(submitState.reset).toHaveBeenCalled(); + expect(useCrowdReportStore.getState().open).toBe(false); + }); + + it("marks the submit button busy while the report is sent", () => { + submitState.isPending = true; + render(); + + const submit = screen.getByRole("button", { name: "crowdReports.submit" }); + expect(submit).toBeDisabled(); + expect(submit).toHaveAttribute("aria-busy", "true"); + expect(screen.getByRole("progressbar")).toBeInTheDocument(); + }); +});