From 0688a6f772f662849c88802d833c4d171f1e23fa Mon Sep 17 00:00:00 2001 From: Theo Date: Thu, 24 Sep 2026 03:56:06 +0000 Subject: [PATCH 01/19] unknown report and user statuses render instead of blanking the page --- .../reports/report-status-fallback.test.tsx | 90 +++++++++++++ .../src/features/reports/reports-page.tsx | 7 +- .../users/user-status-fallback.test.tsx | 119 ++++++++++++++++++ apps/admin/src/features/users/users-page.tsx | 19 +-- 4 files changed, 226 insertions(+), 9 deletions(-) create mode 100644 apps/admin/src/features/reports/report-status-fallback.test.tsx create mode 100644 apps/admin/src/features/users/user-status-fallback.test.tsx diff --git a/apps/admin/src/features/reports/report-status-fallback.test.tsx b/apps/admin/src/features/reports/report-status-fallback.test.tsx new file mode 100644 index 0000000..48e0c2f --- /dev/null +++ b/apps/admin/src/features/reports/report-status-fallback.test.tsx @@ -0,0 +1,90 @@ +import { within } from "@testing-library/react" +import type { + AdminReportDTO, + AdminReportListItemDTO, + AdminReportListResponse, + AdminReportStatus, + ChatHistoryResponse, +} from "@civfix/shared" +import { describe, expect, it, vi } from "vitest" + +import type * as ApiModule from "@/lib/api" +import { apiMock } from "@/test/api-mock" +import { renderWithQuery } from "@/test/render" +import { detailCard, listCard } from "@/test/panes" +import { ReportsPage } from "@/features/reports/reports-page" + +vi.mock("@/lib/api", async (importOriginal) => { + const { apiMock } = await import("@/test/api-mock") + return { ...(await importOriginal()), api: apiMock } +}) + +vi.mock("@/components/map/leaflet-map", () => ({ + LeafletMap: () =>
, +})) + +// The shared client hands back a body that fails its schema as-is, so a status added on the server +// after this build reaches the page untyped. +const ARCHIVED = { + id: "r9a8b7c6-0000-4000-8000-000000000009", + title: "Mattress by the creek", + category: "trash", + status: "archived" as AdminReportStatus, + flagged: false, + place: "Temescal", + reporter: { id: "u-rita", name: "Rita Gomez", handle: "@rita", joined: "Feb 2026" }, + confirmations: 0, + submitted: { rel: "3h", abs: "Sep 23, 2026, 9:00 AM" }, + coords: [37.83, -122.26], + address: "4900 Telegraph Ave", + hasPhoto: false, + thumbnailUrl: null, +} satisfies AdminReportListItemDTO + +const ARCHIVED_DETAIL = { + ...ARCHIVED, + desc: "Description of Mattress by the creek", + timeline: [], + city: { dept: "Oakland Public Works", place: "Temescal", contact: null, routed: false }, + media: [], + linkedEvents: [], + geoid: "0653000", + outreach: { status: "not_sent", threadId: null, routedTo: null, routedAt: null }, + referenceCode: "CF-R9A8", + verificationVerdict: null, + verifiedAt: null, + reporterReportVerified: false, +} satisfies AdminReportDTO + +describe("ReportsPage with a status this build does not know", () => { + it("renders the detail pane and offers no status transition", async () => { + apiMock.listAdminReports.mockResolvedValue({ + items: [ARCHIVED], + nextCursor: null, + counts: { + all: 1, + submitted: 0, + in_progress: 0, + completed: 0, + flagged: 0, + needsVerification: 0, + }, + } satisfies AdminReportListResponse) + apiMock.getAdminReport.mockResolvedValue(ARCHIVED_DETAIL) + apiMock.adminReportMessages.mockResolvedValue({ + items: [], + nextCursor: null, + } satisfies ChatHistoryResponse) + renderWithQuery() + + const card = detailCard() + expect( + await within(card).findByRole("heading", { name: "Mattress by the creek" }), + ).toBeInTheDocument() + expect(within(card).getByText("No status changes from archived")).toBeInTheDocument() + for (const label of ["Acknowledged", "Under review", "Resolved", "Published"]) { + expect(within(card).queryByRole("button", { name: label })).not.toBeInTheDocument() + } + expect(within(listCard()).getByText("Mattress by the creek")).toBeInTheDocument() + }) +}) diff --git a/apps/admin/src/features/reports/reports-page.tsx b/apps/admin/src/features/reports/reports-page.tsx index d4b77a9..944b184 100644 --- a/apps/admin/src/features/reports/reports-page.tsx +++ b/apps/admin/src/features/reports/reports-page.tsx @@ -612,7 +612,8 @@ function ReportDetail({ reportId, onRemoved }: { reportId: string; onRemoved: (i const OutreachIco = outreachView.icon const canCity = !!report.city.contact const routeAction = routeActionFor(report) - const statusActions = ADMIN_REPORT_STATUS_TRANSITIONS[report.status] + // A status newer than this build arrives unvalidated; offer no transition rather than guess one. + const statusActions = ADMIN_REPORT_STATUS_TRANSITIONS[report.status] ?? [] const reporterProfileId = getReporterProfileId(report.reporter.id) const verdictApproved = report.verificationVerdict === "approved" || approvedLocally const verdictView = verdictPill(report.verificationVerdict, approvedLocally) @@ -1287,7 +1288,9 @@ function ReportDetail({ reportId, onRemoved }: { reportId: string; onRemoved: (i
Quick status {statusActions.length === 0 ? ( - No status changes from {ADMIN_REPORT_STATUS_LABELS[report.status]} + + No status changes from {ADMIN_REPORT_STATUS_LABELS[report.status] ?? report.status} + ) : ( statusActions.map((s) => (
- {ORG_ROLE_LABEL[org.role]} + + {ORG_ROLE_LABEL[org.role] ?? org.role} +
) @@ -384,6 +391,7 @@ function UserDetail({ userId }: { userId: string }) { } const deleted = !!user.deletedAt + const statusView = userStatusView(user.status) const tabs: { id: TabId; label: string }[] = [ { id: "reports", label: "Reports" }, @@ -505,9 +513,7 @@ function UserDetail({ userId }: { userId: string }) { Report-verified )} - - {STATUS_VIEW[user.status].label} - + {statusView.label} @@ -619,6 +625,7 @@ function UserRow({ selected: boolean onClick: () => void }) { + const statusView = userStatusView(user.status) return (
@@ -650,9 +657,7 @@ function UserRow({ Deleted )} - - {STATUS_VIEW[user.status].label} - + {statusView.label}
) From 99172e4ceca424747983d3d5569735d70b5481ea Mon Sep 17 00:00:00 2001 From: Theo Date: Thu, 24 Sep 2026 03:56:06 +0000 Subject: [PATCH 02/19] create-org backdrop click keeps a filled-in draft --- .../features/orgs/create-org-panel.test.tsx | 60 +++++++++++++++++++ .../src/features/orgs/create-org-panel.tsx | 26 ++++++-- 2 files changed, 82 insertions(+), 4 deletions(-) create mode 100644 apps/admin/src/features/orgs/create-org-panel.test.tsx diff --git a/apps/admin/src/features/orgs/create-org-panel.test.tsx b/apps/admin/src/features/orgs/create-org-panel.test.tsx new file mode 100644 index 0000000..4cdb596 --- /dev/null +++ b/apps/admin/src/features/orgs/create-org-panel.test.tsx @@ -0,0 +1,60 @@ +import { fireEvent, screen } from "@testing-library/react" +import userEvent from "@testing-library/user-event" +import { describe, expect, it, vi } from "vitest" + +import type * as ApiModule from "@/lib/api" +import { apiMock } from "@/test/api-mock" +import { renderWithQuery } from "@/test/render" +import { CreateOrgPanel } from "@/features/orgs/create-org-panel" + +vi.mock("@/lib/api", async (importOriginal) => { + const { apiMock } = await import("@/test/api-mock") + return { ...(await importOriginal()), api: apiMock } +}) + +// The backdrop is decorative (no role or name), so its class is the only handle on it. +function backdrop(): HTMLElement { + const el = document.querySelector(".panel-overlay") + if (!el) throw new Error("backdrop is not rendered") + return el +} + +function renderPanel() { + apiMock.listAdminUsers.mockReturnValue(new Promise(() => {})) + const onClose = vi.fn() + renderWithQuery() + return { onClose } +} + +describe("CreateOrgPanel backdrop", () => { + it("closes an untouched panel on a backdrop click", async () => { + const { onClose } = renderPanel() + await userEvent.click(backdrop()) + expect(onClose).toHaveBeenCalledTimes(1) + }) + + it("keeps a filled-in draft open on a backdrop click", async () => { + const { onClose } = renderPanel() + await userEvent.type(screen.getByLabelText("Name"), "River Keepers") + + await userEvent.click(backdrop()) + + expect(onClose).not.toHaveBeenCalled() + expect(screen.getByRole("dialog", { name: "New organization" })).toBeInTheDocument() + expect(screen.getByLabelText("Name")).toHaveValue("River Keepers") + }) + + it("ignores a click on the backdrop whose press started inside the panel", () => { + const { onClose } = renderPanel() + fireEvent.pointerDown(screen.getByLabelText("Name")) + fireEvent.click(backdrop()) + expect(onClose).not.toHaveBeenCalled() + }) + + it("still discards a filled-in draft through Cancel", async () => { + const { onClose } = renderPanel() + await userEvent.type(screen.getByLabelText("Name"), "River Keepers") + await userEvent.click(screen.getByRole("button", { name: "Cancel" })) + expect(onClose).toHaveBeenCalledTimes(1) + }) +}) diff --git a/apps/admin/src/features/orgs/create-org-panel.tsx b/apps/admin/src/features/orgs/create-org-panel.tsx index 0a597bb..0eb23b3 100644 --- a/apps/admin/src/features/orgs/create-org-panel.tsx +++ b/apps/admin/src/features/orgs/create-org-panel.tsx @@ -88,9 +88,10 @@ function CreateOrgSlideOver({ onClose() }, [onClose, pending]) - // Escape dismisses an untouched panel; once there is a draft it is ignored (closing discards the - // draft, and Escape is too easy a reflex — dismissing the owner search, say — to make destructive). - // Cancel and the close button remain the deliberate way out. + // Escape and a backdrop click dismiss an untouched panel; once there is a draft both are ignored + // (closing discards the draft, and each is too easy a reflex, dismissing the owner search or a + // stray click beside the panel, to make destructive). Cancel and the close button remain the + // deliberate way out. const pristine = owner === null && draft.logoMediaId === null && @@ -109,6 +110,23 @@ function CreateOrgSlideOver({ return () => window.removeEventListener("keydown", onKey) }, [close, pristine]) + // A browser may deliver the click of a press that began in a field (a text-selection drag) to the + // element it was released over, so only a press that also started on the backdrop counts. + const overlayRef = React.useRef(null) + const pressStartedOnOverlay = React.useRef(false) + React.useEffect(() => { + const onPointerDown = (e: PointerEvent) => { + pressStartedOnOverlay.current = e.target === overlayRef.current + } + window.addEventListener("pointerdown", onPointerDown, true) + return () => window.removeEventListener("pointerdown", onPointerDown, true) + }, []) + const onOverlayClick = (e: React.MouseEvent) => { + const pressed = pressStartedOnOverlay.current + pressStartedOnOverlay.current = false + if (pressed && e.target === e.currentTarget && pristine) close() + } + const localErrors = React.useMemo( () => (submitted ? validateCreate(draft, owner, reason) : {}), [submitted, draft, owner, reason], @@ -160,7 +178,7 @@ function CreateOrgSlideOver({ return ( <> -
+