diff --git a/apps/admin/src/components/map/leaflet-map.test.tsx b/apps/admin/src/components/map/leaflet-map.test.tsx index f414941..208446a 100644 --- a/apps/admin/src/components/map/leaflet-map.test.tsx +++ b/apps/admin/src/components/map/leaflet-map.test.tsx @@ -3,7 +3,7 @@ import L from "leaflet" import { afterEach, beforeEach, describe, expect, it, vi } from "vitest" import { BoundaryMap } from "@/components/map/boundary-map" -import { LeafletMap, type MapPin } from "@/components/map/leaflet-map" +import { LeafletMap, tooltipText, type MapPin } from "@/components/map/leaflet-map" // jsdom has no layout engine: Leaflet sizes the map from clientWidth/clientHeight (0 in jsdom, which // makes fitBounds produce NaN coordinates) and the wrappers observe their container for resizes. @@ -93,17 +93,51 @@ describe("LeafletMap", () => { expect(tip).toBeNull() }) - it("parses a pin title in the tooltip as HTML markup (current behavior)", () => { + it("renders a pin title and place in the tooltip as literal text, never as markup", () => { const pin: MapPin = { id: "h", lat: 34, lng: -118, tip: "x", place: "y" } const { container } = render() const tip = hoverAndReadTooltip(container, markerIcons(container)[0]!) - expect(tip?.querySelector("b")?.textContent).toBe("x") - expect(tip?.querySelector("i")?.textContent).toBe("y") - expect(tip?.textContent).toBe("x · y") - expect(tip?.textContent).not.toContain("") + expect(tip?.querySelector("b")).toBeNull() + expect(tip?.querySelector("i")).toBeNull() + expect(tip?.textContent).toBe("x · y") }) + it("never runs markup from a citizen-authored title in the tooltip", () => { + const payload = '' + const pin: MapPin = { id: "x", lat: 34, lng: -118, tip: payload, place: "" } + const { container } = render() + + const tip = hoverAndReadTooltip(container, markerIcons(container)[0]!) + expect(tip?.querySelector("img, svg")).toBeNull() + expect(tip?.textContent).toBe(`${payload} · `) + }) + + it("keeps a label with entity-like text literal in the tooltip", () => { + const pin: MapPin = { id: "a", lat: 34, lng: -118, label: "Fish & Chips" } + const { container } = render() + + const tip = hoverAndReadTooltip(container, markerIcons(container)[0]!) + expect(tip?.textContent).toBe("Fish & Chips") + }) + + it.each(["constructor", "toString", "__proto__"])( + "draws the generic glyph for the inherited object key %j as a category", + (category) => { + const { container } = render( + , + ) + + const glyphs = markerIcons(container).map((el) => el.querySelector("g path")?.getAttribute("d")) + expect(glyphs[0]).toBe(glyphs[1]) + }, + ) + it("calls onPinTap with the tapped pin", () => { const onPinTap = vi.fn() const { container } = render() @@ -155,3 +189,19 @@ describe("BoundaryMap", () => { expect(remove).toHaveBeenCalledTimes(1) }) }) + +describe("tooltipText", () => { + it("joins the title and place with a middle dot", () => { + expect(tooltipText({ id: "1", lat: 0, lng: 0, tip: "Pothole", place: "Oakland" })).toBe( + "Pothole · Oakland", + ) + }) + + it("falls back to the label and omits a missing place", () => { + expect(tooltipText({ id: "1", lat: 0, lng: 0, label: "Wall" })).toBe("Wall") + }) + + it("returns null when there is no title to show", () => { + expect(tooltipText({ id: "1", lat: 0, lng: 0, place: "Oakland" })).toBeNull() + }) +}) diff --git a/apps/admin/src/components/map/leaflet-map.tsx b/apps/admin/src/components/map/leaflet-map.tsx index e1eba96..33d5176 100644 --- a/apps/admin/src/components/map/leaflet-map.tsx +++ b/apps/admin/src/components/map/leaflet-map.tsx @@ -82,7 +82,10 @@ function pinIcon( // report pin (red when it needs attention, gray when handled). const isEvent = kind != null && EVENT_KINDS.has(kind) const state = isEvent ? kind : draft ? "needs" : "routed" - const glyph = GLYPHS[isEvent ? kind : category || "other"] ?? CATEGORY_GLYPHS.other + const glyphKey = isEvent ? kind : category || "other" + // The icon html goes through innerHTML and the category arrives from the API unvalidated, so only a + // constant own entry of GLYPHS may reach it (an inherited key like "constructor" would not). + const glyph = Object.hasOwn(GLYPHS, glyphKey) ? GLYPHS[glyphKey]! : CATEGORY_GLYPHS.other const fill = PIN_FILL[state] ?? PIN_FILL.routed const w = active ? 40 : 31 const h = w * (76 / 64) @@ -102,6 +105,20 @@ function pinIcon( }) } +export function tooltipText(pin: MapPin): string | null { + const title = pin.tip || pin.label + if (!title) return null + return pin.place ? `${title} · ${pin.place}` : title +} + +// Leaflet writes string tooltip content with innerHTML and pin titles/places are citizen-authored, so +// the content must be a node whose text is set with textContent. +function tooltipNode(text: string): HTMLElement { + const el = document.createElement("span") + el.textContent = text + return el +} + export interface LeafletMapProps { pins?: MapPin[] center?: [number, number] @@ -236,9 +253,9 @@ export function LeafletMap({ } else { const icon = pinIcon(p.category, { active, draft: p.draft, kind: p.kind }) const m = L.marker([p.lat, p.lng], { icon, riseOnHover: true }).addTo(map) - const tip = p.tip || p.label + const tip = tooltipText(p) if (tip) { - m.bindTooltip(tip + (p.place ? ` · ${p.place}` : ""), { + m.bindTooltip(tooltipNode(tip), { direction: "top", offset: [0, -30], className: "pi-map-tip", diff --git a/apps/admin/src/components/providers.test.tsx b/apps/admin/src/components/providers.test.tsx new file mode 100644 index 0000000..20a4010 --- /dev/null +++ b/apps/admin/src/components/providers.test.tsx @@ -0,0 +1,48 @@ +import { render, screen, within } from "@testing-library/react" +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest" + +import { Providers } from "@/components/providers" + +vi.mock("@/components/auth/auth-hydrator", () => ({ AuthHydrator: () => null })) +vi.mock("@/hooks/use-admin-auth", () => ({ + useOperatorSession: () => ({ isOperator: true, operator: null, status: "authenticated" }), +})) + +function Crash(): never { + throw new Error("shell exploded") +} + +beforeEach(() => { + vi.spyOn(console, "error").mockImplementation(() => undefined) +}) + +afterEach(() => { + vi.restoreAllMocks() +}) + +describe("Providers", () => { + it("renders the operator's app content", () => { + render( + +

Dashboard content

+ , + ) + expect(screen.getByText("Dashboard content")).toBeInTheDocument() + }) + + it("catches a render error anywhere in the app with a root alert offering Reload", () => { + render( + + + , + ) + + const alert = screen.getByRole("alert") + expect(alert).toHaveTextContent("Something went wrong") + expect(alert).toHaveTextContent( + "This page hit an unexpected error. Try again, or reload the dashboard.", + ) + expect(alert).not.toHaveTextContent("shell exploded") + expect(within(alert).getByRole("button", { name: "Reload" })).toBeInTheDocument() + }) +}) diff --git a/apps/admin/src/components/providers.tsx b/apps/admin/src/components/providers.tsx index 8b9b4aa..ade6b43 100644 --- a/apps/admin/src/components/providers.tsx +++ b/apps/admin/src/components/providers.tsx @@ -5,6 +5,7 @@ import { QueryClientProvider, type QueryClient } from "@tanstack/react-query" import { makeQueryClient } from "@/lib/query" import { AuthHydrator } from "@/components/auth/auth-hydrator" +import { ErrorBoundary } from "@/components/shell/error-boundary" import { OperatorLogin } from "@/features/auth/operator-login" import { useOperatorSession } from "@/hooks/use-admin-auth" @@ -23,8 +24,10 @@ export function Providers({ children }: { children: React.ReactNode }) { return ( - - {children} + + + {children} + ) } diff --git a/apps/admin/src/components/shared/backdrop-dismiss.ts b/apps/admin/src/components/shared/backdrop-dismiss.ts new file mode 100644 index 0000000..39b11a6 --- /dev/null +++ b/apps/admin/src/components/shared/backdrop-dismiss.ts @@ -0,0 +1,61 @@ +"use client" + +import * as React from "react" + +interface BackdropProps { + ref: React.RefObject + onClick: React.MouseEventHandler +} + +// A press that starts in the modal (a text-selection drag, typically) and is released over the +// backdrop must not dismiss: browsers dispatch that click on the nearest common ancestor of press and +// release, which is the backdrop itself when it wraps the modal. So only a click whose press also +// started on the backdrop counts. The press is read on the window in the capture phase because a +// backdrop that is a sibling of its panel never receives a press that began inside the panel. +export function useBackdropDismiss( + onDismiss: () => void, +): BackdropProps { + const ref = React.useRef(null) + const pressedOnBackdrop = React.useRef(false) + + React.useEffect(() => { + const onPointerDown = (e: PointerEvent) => { + pressedOnBackdrop.current = ref.current !== null && e.target === ref.current + } + window.addEventListener("pointerdown", onPointerDown, true) + return () => window.removeEventListener("pointerdown", onPointerDown, true) + }, []) + + const onClick = (e: React.MouseEvent) => { + const dismiss = pressedOnBackdrop.current && e.target === e.currentTarget + pressedOnBackdrop.current = false + if (dismiss) onDismiss() + } + + return { ref, onClick } +} + +/** + * Escape and the backdrop dismiss a modal only while it holds nothing the operator would lose; once + * there is a draft, its close button and Cancel are the deliberate ways to discard it. Returns the + * backdrop props. The shell yields Escape to any open modal (escape-owner.ts), so Escape here closes + * only the modal and never navigates. + */ +export function usePristineDismiss( + onClose: () => void, + pristine: boolean, +): BackdropProps { + const dismiss = React.useCallback(() => { + if (pristine) onClose() + }, [pristine, onClose]) + + React.useEffect(() => { + const onKey = (e: KeyboardEvent) => { + if (e.key === "Escape") dismiss() + } + window.addEventListener("keydown", onKey) + return () => window.removeEventListener("keydown", onKey) + }, [dismiss]) + + return useBackdropDismiss(dismiss) +} diff --git a/apps/admin/src/components/shared/data-states.tsx b/apps/admin/src/components/shared/data-states.tsx index b8058f2..1780627 100644 --- a/apps/admin/src/components/shared/data-states.tsx +++ b/apps/admin/src/components/shared/data-states.tsx @@ -29,27 +29,42 @@ export function LoadingState({ label = "Loading..." }: { label?: string }) { } /** - * Error panel with an optional retry. Surfaces the normalized AppError message; when the backend is + * Error panel with an optional retry and an optional primary action beside it (the error boundary's + * Reload). Surfaces the normalized AppError message unless `message` replaces it; when the backend is * down this renders the friendly INTERNAL message rather than crashing. */ export function ErrorState({ error, onRetry, title = "Could not load this", + message, + action, }: { error: unknown onRetry?: () => void title?: string + message?: string + action?: { label: string; onClick: () => void } }) { - const message = React.useMemo(() => toAppError(error).message, [error]) + const errorText = React.useMemo(() => toAppError(error).message, [error]) + const retry = onRetry && ( + + ) return (
{title}
-
{message}
- {onRetry && ( - +
{message ?? errorText}
+ {action ? ( +
+ {retry} + +
+ ) : ( + retry )}
) diff --git a/apps/admin/src/components/shared/dialog.tsx b/apps/admin/src/components/shared/dialog.tsx index 89745f3..fe10657 100644 --- a/apps/admin/src/components/shared/dialog.tsx +++ b/apps/admin/src/components/shared/dialog.tsx @@ -4,6 +4,7 @@ import * as React from "react" import { create } from "zustand" import { Icons } from "@/components/icons" +import { useBackdropDismiss } from "@/components/shared/backdrop-dismiss" import { useModalFocus } from "@/components/shared/modal-focus" interface ConfirmRequest { @@ -57,16 +58,43 @@ export function promptDialog( }) } +const NATIVE_ENTER_TAGS = new Set(["BUTTON", "A", "SELECT"]) + +// The window listener sees Enter from every focused control. A focused button, link or select keeps +// its own activation (Enter on Close or Cancel must not confirm). A confirm is accepted only by +// activating its own button: many confirms grant access or email a city irreversibly, so a stray or +// doubled Enter must never commit one. +function enterAccepts({ + request, + targetTag, + modified, +}: { + request: DialogRequest + targetTag: string | undefined + modified: boolean +}): boolean { + if (targetTag && NATIVE_ENTER_TAGS.has(targetTag)) return false + if (targetTag === "TEXTAREA" && !modified) return false + if (request.kind === "confirm") return false + return modified +} + export function DialogHost() { const current = useDialogStore((s) => s.current) const close = useDialogStore((s) => s.close) const [value, setValue] = React.useState("") const modalRef = useModalFocus(current !== null) + const cancelRef = React.useRef(null) React.useEffect(() => { if (current?.kind === "prompt") setValue(current.defaultValue ?? "") }, [current]) + // Runs after useModalFocus's effect, so that hook has already recorded the opener to restore. + React.useEffect(() => { + if (current?.kind === "confirm") cancelRef.current?.focus() + }, [current]) + const cancel = React.useCallback(() => { if (!current) return if (current.kind === "prompt") current.resolve(null) @@ -91,7 +119,15 @@ export function DialogHost() { if (e.key === "Escape") { e.preventDefault() cancel() - } else if (e.key === "Enter" && (current.kind === "confirm" || e.metaKey || e.ctrlKey)) { + return + } + if (e.key !== "Enter") return + const targetTag = (e.target as HTMLElement | null)?.tagName + if (e.repeat) { + // A held key's repeats must not answer the dialog its first press opened, neither here nor by + // natively clicking whichever button now has focus. A textarea keeps them as newlines. + if (targetTag !== "TEXTAREA") e.preventDefault() + } else if (enterAccepts({ request: current, targetTag, modified: e.metaKey || e.ctrlKey })) { e.preventDefault() accept() } @@ -100,16 +136,17 @@ export function DialogHost() { return () => window.removeEventListener("keydown", onKey) }, [current, cancel, accept]) + const backdrop = useBackdropDismiss(cancel) + if (!current) return null const confirmDisabled = current.kind === "prompt" && current.required === true && value.trim() === "" return ( -
+
e.stopPropagation()} role="dialog" aria-modal="true" > @@ -136,7 +173,7 @@ export function DialogHost() { )}
-
@@ -164,11 +182,11 @@ function LinkReportsPicker({
setQuery(e.target.value)} - autoFocus />
{listQuery.isLoading ? ( @@ -244,6 +262,7 @@ function EventRow({ }) { const kindView = eventKindView(item.eventKind) const KindIco = kindView.icon + const statusPill = eventStatusView(item.status) const nav = useNav() return (
@@ -290,9 +309,7 @@ function EventRow({
- - {STATUS_VIEW[item.status].label} - + {statusPill.label} {item.date.abs}
@@ -373,6 +390,7 @@ function EventDetail({ eventId, onCancelled }: { eventId: string; onCancelled: ( } const cancelBlockedReason = cancelBlockedFor(event.status) + const statusPill = eventStatusView(event.status) const onCancel = async () => { const ok = await confirmDialog({ @@ -444,10 +462,10 @@ function EventDetail({ eventId, onCancelled }: { eventId: string; onCancelled: ( )} - {STATUS_VIEW[event.status].label} + {statusPill.label} @@ -821,10 +839,10 @@ export function EventsPage({ focusId }: SectionPageProps) { { value: "upcoming", label: "Upcoming", count: counts.upcoming }, { value: "in_progress", - label: STATUS_VIEW.in_progress.label, + label: EVENT_STATUS_VIEW.in_progress.label, count: counts.in_progress, }, - { value: "completed", label: STATUS_VIEW.completed.label, count: counts.completed }, + { value: "completed", label: EVENT_STATUS_VIEW.completed.label, count: counts.completed }, { value: "flagged", label: "Flagged", count: counts.flagged }, ]} value={filter} diff --git a/apps/admin/src/features/events/link-reports-modal.test.tsx b/apps/admin/src/features/events/link-reports-modal.test.tsx new file mode 100644 index 0000000..6dbe872 --- /dev/null +++ b/apps/admin/src/features/events/link-reports-modal.test.tsx @@ -0,0 +1,192 @@ +import { screen, within } from "@testing-library/react" +import userEvent from "@testing-library/user-event" +import type { + AdminEventDTO, + AdminEventListItemDTO, + AdminEventListResponse, + AdminReportListItemDTO, + AdminReportListResponse, +} from "@civfix/shared" +import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest" + +import type * as ApiModule from "@/lib/api" +import { AppShell } from "@/components/shell/app-shell" +import { apiMock } from "@/test/api-mock" +import { renderWithQuery } from "@/test/render" +import { dragOutToBackdrop, overlayOf } from "@/test/modal" +import { waitForSectionPage } from "@/test/shell" +import { useUiStore } from "@/store/ui-store" + +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: () =>
, +})) + +const EVENT = { + id: "aaaa1111-0000-4000-8000-000000000001", + title: "Beach sweep", + status: "upcoming", + eventKind: "cleanup", + flagged: false, + place: "Ocean Beach", + attendees: 4, + capacity: null, + bags: 0, + organizer: { id: "u-org", name: "Olive Park", handle: "@olive", joined: "Mar 2026" }, + date: { rel: "in 3 days", abs: "Sep 26, 9:00 AM" }, + coords: [37.8, -122.26], +} satisfies AdminEventListItemDTO + +const REPORT = { + id: "r-1", + title: "Trash pile by the pier", + category: "trash", + status: "published", + flagged: false, + place: "Ocean Beach", + 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.8, -122.26], + address: "Great Hwy", + hasPhoto: false, + thumbnailUrl: null, +} satisfies AdminReportListItemDTO + +const TITLE = "Link reports" + +// Rendered inside the real shell so its global "Escape goes home" handler is live. +async function openPicker() { + apiMock.listAdminEvents.mockResolvedValue({ + items: [EVENT], + nextCursor: null, + counts: { all: 1, upcoming: 1, in_progress: 0, completed: 0, flagged: 0 }, + } satisfies AdminEventListResponse) + apiMock.getAdminEvent.mockResolvedValue({ + ...EVENT, + desc: "About the sweep", + address: "Ocean Beach main entrance", + timeline: [], + messages: [], + linkedReports: [], + } satisfies AdminEventDTO) + apiMock.listAdminReports.mockResolvedValue({ + items: [REPORT], + nextCursor: null, + } as AdminReportListResponse) + const user = userEvent.setup() + renderWithQuery() + await waitForSectionPage() + // Wait on cheap text, then query the role once: a role query walks the whole shell on every DOM + // mutation, and on a loaded machine those walks starve the renders it is waiting for. + await screen.findByText("About the sweep") + const opener = screen.getByRole("button", { name: "Link reports" }) + await user.click(opener) + const dialog = screen.getByRole("dialog", { name: TITLE }) + await within(dialog).findByText(REPORT.title) + return { user, opener, dialog } +} + +function reportRow(dialog: HTMLElement): HTMLElement { + return within(dialog).getByRole("button", { name: new RegExp(REPORT.title) }) +} + +// The section page is a React.lazy chunk: the first render in this worker would otherwise pay the cold +// module transform inside the first wait's 1000 ms budget, which a loaded machine overruns. +beforeAll(async () => { + await import("@/features/events/events-page") +}) + +beforeEach(() => { + window.history.replaceState(null, "", "#/events") + useUiStore.setState({ page: "events", focusId: null }) +}) + +describe("Events link-reports modal", () => { + it("is a labelled modal dialog with a named close button", async () => { + const { dialog } = await openPicker() + expect(dialog).toHaveAttribute("aria-modal", "true") + expect(within(dialog).getByRole("button", { name: "Close" })).toBeInTheDocument() + }) + + it("starts in the search field and keeps Tab inside the dialog", async () => { + const { user, dialog } = await openPicker() + expect(within(dialog).getByPlaceholderText("Search title, place, reporter…")).toHaveFocus() + + for (let i = 0; i < 8; i++) { + await user.tab() + expect(dialog).toContainElement(document.activeElement as HTMLElement) + } + for (let i = 0; i < 8; i++) { + await user.tab({ shift: true }) + expect(dialog).toContainElement(document.activeElement as HTMLElement) + } + }) + + it("keeps a selection on Escape from a button and does not send the shell home", async () => { + const { user, dialog } = await openPicker() + await user.click(reportRow(dialog)) + expect(within(dialog).getByText("1 selected")).toBeInTheDocument() + + await user.keyboard("{Escape}") + + expect(screen.getByRole("dialog", { name: TITLE })).toBeInTheDocument() + expect(screen.getByText("1 selected")).toBeInTheDocument() + expect(window.location.hash).toBe("#/events") + expect(useUiStore.getState().page).toBe("events") + }) + + it("keeps a selection on a backdrop click", async () => { + const { user, dialog } = await openPicker() + await user.click(reportRow(dialog)) + + await user.click(overlayOf(dialog)) + + expect(screen.getByRole("dialog", { name: TITLE })).toBeInTheDocument() + expect(screen.getByText("1 selected")).toBeInTheDocument() + }) + + it("closes with nothing selected on Escape without leaving Events, and restores focus", async () => { + const { user, opener, dialog } = await openPicker() + within(dialog).getByRole("button", { name: "Cancel" }).focus() + + await user.keyboard("{Escape}") + + expect(screen.queryByRole("dialog")).toBeNull() + expect(window.location.hash).toBe("#/events") + expect(useUiStore.getState().page).toBe("events") + expect(opener).toHaveFocus() + }) + + it("closes with nothing selected on a backdrop click", async () => { + const { user, dialog } = await openPicker() + await user.click(overlayOf(dialog)) + expect(screen.queryByRole("dialog")).toBeNull() + }) + + it("stays open with its search, nothing selected, when a press in it is released over the backdrop", async () => { + const { user, dialog } = await openPicker() + const search = within(dialog).getByPlaceholderText("Search title, place, reporter…") + await user.type(search, "pier") + + dragOutToBackdrop(search, overlayOf(dialog)) + + expect(screen.getByRole("dialog", { name: TITLE })).toBeInTheDocument() + expect(screen.getByPlaceholderText("Search title, place, reporter…")).toHaveValue("pier") + expect(screen.getByText("Select reports to link")).toBeInTheDocument() + }) + + it("drops a selection only through the close button, and returns focus to the opener", async () => { + const { user, opener, dialog } = await openPicker() + await user.click(reportRow(dialog)) + + await user.click(within(dialog).getByRole("button", { name: "Close" })) + + expect(screen.queryByRole("dialog")).toBeNull() + expect(opener).toHaveFocus() + }) +}) diff --git a/apps/admin/src/features/inbox/attachment-chip.tsx b/apps/admin/src/features/inbox/attachment-chip.tsx new file mode 100644 index 0000000..19c95c1 --- /dev/null +++ b/apps/admin/src/features/inbox/attachment-chip.tsx @@ -0,0 +1,46 @@ +"use client" + +import type { MailAttachment } from "@civfix/shared" + +import { Icons } from "@/components/icons" +import { isWebUrl } from "@/lib/external-url" + +function sizeLabel(bytes: number): string { + return `${(bytes / 1024).toFixed(0)}k` +} + +// An attachment key comes from mail the platform received, so only an http(s) url becomes a link; +// any other scheme (javascript:, data:) or a bare storage key renders inert. +export function AttachmentChip({ attachment }: { attachment: MailAttachment }) { + if (!isWebUrl(attachment.key)) { + return ( + + {attachment.filename} + {sizeLabel(attachment.size)} + link unavailable + + ) + } + return ( + + {attachment.filename} + {sizeLabel(attachment.size)} + + ) +} + +export function AttachmentList({ attachments }: { attachments: MailAttachment[] }) { + if (attachments.length === 0) return null + return ( +
+ {attachments.map((att) => ( + + ))} +
+ ) +} diff --git a/apps/admin/src/features/inbox/inbox-attachments.test.tsx b/apps/admin/src/features/inbox/inbox-attachments.test.tsx new file mode 100644 index 0000000..4837e19 --- /dev/null +++ b/apps/admin/src/features/inbox/inbox-attachments.test.tsx @@ -0,0 +1,60 @@ +import { screen } from "@testing-library/react" +import type { InboundEmailDTO } 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 { InboxReader } from "@/features/inbox/inbox-views" + +vi.mock("@/lib/api", async (importOriginal) => { + const { apiMock } = await import("@/test/api-mock") + return { ...(await importOriginal()), api: apiMock } +}) + +const MESSAGE = { + id: "i-1", + from: "resident@example.com", + recipient: "hello@civfix.org", + localPart: "hello", + subject: "Photos of the dumping", + preview: "See attached", + ts: "2026-09-20T15:00:00.000Z", + status: "read", + unread: false, + hasAttachments: true, + bodyText: "See attached", + bodyHtml: null, + messageId: null, + attachments: [ + { key: "https://media.civfix.org/att/1?sig=abc", filename: "dumping.jpg", size: 20480 }, + { key: "http://localhost:8080/_local-storage/att/2", filename: "map.pdf", size: 4096 }, + { key: "javascript:alert(1)", filename: "invoice.html", size: 1024 }, + { key: "inbound/2026/att-4.bin", filename: "raw.bin", size: 2048 }, + ], +} satisfies InboundEmailDTO + +describe("InboxReader attachments", () => { + it("links only http(s) attachment urls, in a new tab without opener or referrer", async () => { + apiMock.getInboxMessage.mockResolvedValue(MESSAGE) + renderWithQuery() + + const photo = await screen.findByRole("link", { name: /dumping\.jpg/ }) + expect(photo).toHaveAttribute("href", "https://media.civfix.org/att/1?sig=abc") + expect(photo).toHaveAttribute("target", "_blank") + expect(photo).toHaveAttribute("rel", "noopener noreferrer") + expect(screen.getByRole("link", { name: /map\.pdf/ })).toHaveAttribute( + "href", + "http://localhost:8080/_local-storage/att/2", + ) + + expect(screen.getAllByRole("link")).toHaveLength(2) + expect(screen.getByText("invoice.html")).toBeInTheDocument() + expect(screen.getByText("raw.bin")).toBeInTheDocument() + expect(screen.getByText("invoice.html").closest("a")).toBeNull() + expect(screen.getByText("invoice.html")).toHaveTextContent("link unavailable") + expect(screen.getByText("raw.bin")).toHaveTextContent("link unavailable") + expect(screen.getAllByText("link unavailable")).toHaveLength(2) + expect(document.querySelector('a[href^="javascript:"]')).toBeNull() + }) +}) diff --git a/apps/admin/src/features/inbox/inbox-views.tsx b/apps/admin/src/features/inbox/inbox-views.tsx index a92a518..7c04bef 100644 --- a/apps/admin/src/features/inbox/inbox-views.tsx +++ b/apps/admin/src/features/inbox/inbox-views.tsx @@ -12,6 +12,7 @@ import { import { Icons } from "@/components/icons" import { EmptyState } from "@/components/shared/page-primitives" import { LoadingState, ErrorState } from "@/components/shared/data-states" +import { AttachmentList } from "@/features/inbox/attachment-chip" import { useInboxMessage, useSetInboxStatus } from "@/features/inbox/use-inbox" import { replyOriginLabel } from "@/features/inbox/inbox-feed" import { AuthVerdictBadge, PublicationBadge } from "@/features/mail/mail-badges" @@ -129,24 +130,7 @@ export function InboxReader({ id }: { id: string }) {
- {sel.attachments.length > 0 && ( -
- {sel.attachments.map((att) => ( - - {att.filename} - - {(att.size / 1024).toFixed(0)}k - - - ))} -
- )} +
diff --git a/apps/admin/src/features/mail/compose-modal.test.tsx b/apps/admin/src/features/mail/compose-modal.test.tsx new file mode 100644 index 0000000..421bdac --- /dev/null +++ b/apps/admin/src/features/mail/compose-modal.test.tsx @@ -0,0 +1,150 @@ +import { screen, within } from "@testing-library/react" +import userEvent from "@testing-library/user-event" +import type { + GetForwardTemplateDefaultResponse, + InboxListResponse, + MailListResponse, + MailStatsResponse, +} from "@civfix/shared" +import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest" + +import type * as ApiModule from "@/lib/api" +import { AppShell } from "@/components/shell/app-shell" +import { apiMock } from "@/test/api-mock" +import { renderWithQuery } from "@/test/render" +import { dragOutToBackdrop, overlayOf } from "@/test/modal" +import { waitForSectionPage } from "@/test/shell" +import { useUiStore } from "@/store/ui-store" + +vi.mock("@/lib/api", async (importOriginal) => { + const { apiMock } = await import("@/test/api-mock") + return { ...(await importOriginal()), api: apiMock } +}) + +// Rendered inside the real shell so its global "Escape goes home" handler is live: that handler is +// what used to navigate away and drop the draft. +async function openMail() { + apiMock.getMailStats.mockResolvedValue({ + unread: 0, + threads: 0, + sent: 0, + bounced: 0, + failed: 0, + } satisfies MailStatsResponse) + apiMock.getForwardTemplateDefault.mockResolvedValue({ + subjectTemplate: null, + bodyTemplate: null, + updatedAt: null, + } satisfies GetForwardTemplateDefaultResponse) + apiMock.listInbox.mockResolvedValue({ items: [], nextCursor: null } satisfies InboxListResponse) + apiMock.listMail.mockResolvedValue({ items: [], nextCursor: null } satisfies MailListResponse) + const user = userEvent.setup() + renderWithQuery() + await waitForSectionPage() + const opener = screen.getByRole("button", { name: "Compose" }) + return { user, opener } +} + +async function openCompose() { + const ctx = await openMail() + await ctx.user.click(ctx.opener) + return { ...ctx, dialog: screen.getByRole("dialog", { name: "New message" }) } +} + +// The section page is a React.lazy chunk: the first render in this worker would otherwise pay the cold +// module transform inside the first wait's 1000 ms budget, which a loaded machine overruns. +beforeAll(async () => { + await import("@/features/mail/mail-page") +}) + +beforeEach(() => { + window.history.replaceState(null, "", "#/mail") + useUiStore.setState({ page: "mail", focusId: null }) +}) + +describe("Mail compose modal", () => { + it("is a labelled modal dialog with a named close button", async () => { + const { dialog } = await openCompose() + expect(dialog).toHaveAttribute("aria-modal", "true") + expect(within(dialog).getByRole("button", { name: "Close" })).toBeInTheDocument() + }) + + it("starts in the To field and keeps Tab inside the dialog", async () => { + const { user, dialog } = await openCompose() + expect(within(dialog).getByLabelText("To")).toHaveFocus() + + for (let i = 0; i < 8; i++) { + await user.tab() + expect(dialog).toContainElement(document.activeElement as HTMLElement) + } + for (let i = 0; i < 8; i++) { + await user.tab({ shift: true }) + expect(dialog).toContainElement(document.activeElement as HTMLElement) + } + }) + + it("keeps a dirty draft on Escape from a button and does not send the shell home", async () => { + const { user, dialog } = await openCompose() + await user.type(within(dialog).getByLabelText("To"), "works@oaklandca.gov") + await user.type(within(dialog).getByLabelText("Subject"), "Pothole") + await user.type(within(dialog).getByLabelText("Message"), "Please take a look.") + within(dialog).getByRole("button", { name: "Send" }).focus() + + await user.keyboard("{Escape}") + + expect(screen.getByRole("dialog", { name: "New message" })).toBeInTheDocument() + expect(screen.getByLabelText("To")).toHaveValue("works@oaklandca.gov") + expect(screen.getByLabelText("Message")).toHaveValue("Please take a look.") + expect(window.location.hash).toBe("#/mail") + expect(useUiStore.getState().page).toBe("mail") + }) + + it("keeps a dirty draft on a backdrop click", async () => { + const { user, dialog } = await openCompose() + await user.type(within(dialog).getByLabelText("Subject"), "Pothole") + + await user.click(overlayOf(dialog)) + + expect(screen.getByRole("dialog", { name: "New message" })).toBeInTheDocument() + expect(screen.getByLabelText("Subject")).toHaveValue("Pothole") + }) + + it("closes an empty draft on Escape without leaving Mail, and restores focus", async () => { + const { user, opener, dialog } = await openCompose() + within(dialog).getByRole("button", { name: "Cancel" }).focus() + + await user.keyboard("{Escape}") + + expect(screen.queryByRole("dialog")).toBeNull() + expect(window.location.hash).toBe("#/mail") + expect(useUiStore.getState().page).toBe("mail") + expect(opener).toHaveFocus() + }) + + it("closes an empty draft on a backdrop click", async () => { + const { user, dialog } = await openCompose() + await user.click(overlayOf(dialog)) + expect(screen.queryByRole("dialog")).toBeNull() + }) + + it("stays open, even with an empty draft, when a press in a field is released over the backdrop", async () => { + const { dialog } = await openCompose() + + dragOutToBackdrop(within(dialog).getByLabelText("To"), overlayOf(dialog)) + + expect(screen.getByRole("dialog", { name: "New message" })).toBeInTheDocument() + expect(screen.getByLabelText("To")).toHaveValue("") + }) + + it("discards a dirty draft only through the close button, and returns focus to Compose", async () => { + const { user, opener, dialog } = await openCompose() + await user.type(within(dialog).getByLabelText("Subject"), "Pothole") + + await user.click(within(dialog).getByRole("button", { name: "Close" })) + + expect(screen.queryByRole("dialog")).toBeNull() + expect(opener).toHaveFocus() + await user.click(opener) + expect(screen.getByLabelText("Subject")).toHaveValue("") + }) +}) diff --git a/apps/admin/src/features/mail/forward-template-modal.test.tsx b/apps/admin/src/features/mail/forward-template-modal.test.tsx new file mode 100644 index 0000000..410a0fd --- /dev/null +++ b/apps/admin/src/features/mail/forward-template-modal.test.tsx @@ -0,0 +1,147 @@ +import { screen, within } from "@testing-library/react" +import userEvent from "@testing-library/user-event" +import { + DEFAULT_FORWARD_SUBJECT_TEMPLATE, + type GetForwardTemplateDefaultResponse, + type InboxListResponse, + type MailListResponse, + type MailStatsResponse, +} from "@civfix/shared" +import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest" + +import type * as ApiModule from "@/lib/api" +import { AppShell } from "@/components/shell/app-shell" +import { apiMock } from "@/test/api-mock" +import { renderWithQuery } from "@/test/render" +import { dragOutToBackdrop, overlayOf } from "@/test/modal" +import { waitForSectionPage } from "@/test/shell" +import { useUiStore } from "@/store/ui-store" + +vi.mock("@/lib/api", async (importOriginal) => { + const { apiMock } = await import("@/test/api-mock") + return { ...(await importOriginal()), api: apiMock } +}) + +const TITLE = "Default forwarding email" + +// Rendered inside the real shell so its global "Escape goes home" handler is live. +async function openTemplate() { + apiMock.getMailStats.mockResolvedValue({ + unread: 0, + threads: 0, + sent: 0, + bounced: 0, + failed: 0, + } satisfies MailStatsResponse) + apiMock.getForwardTemplateDefault.mockResolvedValue({ + subjectTemplate: null, + bodyTemplate: null, + updatedAt: null, + } satisfies GetForwardTemplateDefaultResponse) + apiMock.listInbox.mockResolvedValue({ items: [], nextCursor: null } satisfies InboxListResponse) + apiMock.listMail.mockResolvedValue({ items: [], nextCursor: null } satisfies MailListResponse) + const user = userEvent.setup() + renderWithQuery() + await waitForSectionPage() + const opener = screen.getByRole("button", { name: "Default template" }) + await vi.waitFor(() => expect(opener).toBeEnabled()) + await user.click(opener) + return { user, opener, dialog: screen.getByRole("dialog", { name: TITLE }) } +} + +// The section page is a React.lazy chunk: the first render in this worker would otherwise pay the cold +// module transform inside the first wait's 1000 ms budget, which a loaded machine overruns. +beforeAll(async () => { + await import("@/features/mail/mail-page") +}) + +beforeEach(() => { + window.history.replaceState(null, "", "#/mail") + useUiStore.setState({ page: "mail", focusId: null }) +}) + +describe("Forward template modal", () => { + it("is a labelled modal dialog with a named close button", async () => { + const { dialog } = await openTemplate() + expect(dialog).toHaveAttribute("aria-modal", "true") + expect(within(dialog).getByRole("button", { name: "Close" })).toBeInTheDocument() + }) + + it("moves focus inside and keeps Tab inside the dialog", async () => { + const { user, dialog } = await openTemplate() + expect(dialog).toContainElement(document.activeElement as HTMLElement) + + for (let i = 0; i < 20; i++) { + await user.tab() + expect(dialog).toContainElement(document.activeElement as HTMLElement) + } + for (let i = 0; i < 20; i++) { + await user.tab({ shift: true }) + expect(dialog).toContainElement(document.activeElement as HTMLElement) + } + }) + + it("keeps an edited template on Escape from a button and does not send the shell home", async () => { + const { user, dialog } = await openTemplate() + const subject = within(dialog).getByLabelText("Subject") + await user.clear(subject) + await user.type(subject, "Oakland pothole report") + within(dialog).getByRole("button", { name: "Preview" }).focus() + + await user.keyboard("{Escape}") + + expect(screen.getByRole("dialog", { name: TITLE })).toBeInTheDocument() + expect(screen.getByLabelText("Subject")).toHaveValue("Oakland pothole report") + expect(window.location.hash).toBe("#/mail") + expect(useUiStore.getState().page).toBe("mail") + }) + + it("keeps an edited template on a backdrop click", async () => { + const { user, dialog } = await openTemplate() + await user.click(within(dialog).getByRole("button", { name: "Clear" })) + + await user.click(overlayOf(dialog)) + + expect(screen.getByRole("dialog", { name: TITLE })).toBeInTheDocument() + expect(screen.getByLabelText("Subject")).toHaveValue("") + }) + + it("closes an unedited template on Escape without leaving Mail, and restores focus", async () => { + const { user, opener, dialog } = await openTemplate() + within(dialog).getByRole("button", { name: "Cancel" }).focus() + + await user.keyboard("{Escape}") + + expect(screen.queryByRole("dialog")).toBeNull() + expect(window.location.hash).toBe("#/mail") + expect(useUiStore.getState().page).toBe("mail") + expect(opener).toHaveFocus() + }) + + it("closes an unedited template on a backdrop click", async () => { + const { user, dialog } = await openTemplate() + await user.click(overlayOf(dialog)) + expect(screen.queryByRole("dialog")).toBeNull() + }) + + it("stays open, even unedited, when a press in a field is released over the backdrop", async () => { + const { dialog } = await openTemplate() + + dragOutToBackdrop(within(dialog).getByLabelText("Subject"), overlayOf(dialog)) + + expect(screen.getByRole("dialog", { name: TITLE })).toBeInTheDocument() + expect(screen.getByLabelText("Subject")).toHaveValue(DEFAULT_FORWARD_SUBJECT_TEMPLATE) + }) + + it("discards an edit only through Cancel, and returns focus to the opener", async () => { + const { user, opener, dialog } = await openTemplate() + await user.click(within(dialog).getByRole("button", { name: "Clear" })) + + await user.click(within(dialog).getByRole("button", { name: "Cancel" })) + + expect(screen.queryByRole("dialog")).toBeNull() + expect(opener).toHaveFocus() + await user.click(opener) + expect(screen.getByLabelText("Subject")).toHaveValue(DEFAULT_FORWARD_SUBJECT_TEMPLATE) + }) +}) diff --git a/apps/admin/src/features/mail/forward-template-modal.tsx b/apps/admin/src/features/mail/forward-template-modal.tsx index 22e05bc..7766253 100644 --- a/apps/admin/src/features/mail/forward-template-modal.tsx +++ b/apps/admin/src/features/mail/forward-template-modal.tsx @@ -12,6 +12,8 @@ import { } from "@civfix/shared" import { Icons } from "@/components/icons" +import { usePristineDismiss } from "@/components/shared/backdrop-dismiss" +import { useModalFocus } from "@/components/shared/modal-focus" import { toAppError } from "@/lib/api" import { usePreviewForwardTemplate } from "@/features/mail/use-mail" import { @@ -68,9 +70,12 @@ function ForwardTemplateEditor({ pending, }: ForwardTemplateModalProps) { const preview = usePreviewForwardTemplate() - const [subject, setSubject] = React.useState(() => resolveTemplateSeed(initial, fallback).subject) - const [body, setBody] = React.useState(() => resolveTemplateSeed(initial, fallback).body) + const [seed] = React.useState(() => resolveTemplateSeed(initial, fallback)) + const [subject, setSubject] = React.useState(seed.subject) + const [body, setBody] = React.useState(seed.body) const [rendered, setRendered] = React.useState(null) + const modalRef = useModalFocus(true) + const titleId = React.useId() const subjectRef = React.useRef(null) const bodyRef = React.useRef(null) @@ -98,6 +103,8 @@ function ForwardTemplateEditor({ const bodyIssues = forwardTemplateIssues(body) const hasIssues = subjectIssues.length > 0 || bodyIssues.length > 0 + const backdrop = usePristineDismiss(onClose, subject === seed.subject && body === seed.body) + const onPreview = () => { const input: PreviewForwardTemplateRequest = {} if (subject.trim() !== "") input.subjectTemplate = subject @@ -106,10 +113,16 @@ function ForwardTemplateEditor({ } return ( -
-
e.stopPropagation()}> +
+
-

{title}

+

{title}

diff --git a/apps/admin/src/features/mail/mail-attachments.test.tsx b/apps/admin/src/features/mail/mail-attachments.test.tsx new file mode 100644 index 0000000..6b963cc --- /dev/null +++ b/apps/admin/src/features/mail/mail-attachments.test.tsx @@ -0,0 +1,91 @@ +import { screen } from "@testing-library/react" +import type { + GetForwardTemplateDefaultResponse, + InboxListResponse, + MailListResponse, + MailStatsResponse, + MailThreadDTO, + MailThreadListItemDTO, +} 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 } from "@/test/panes" +import { MailPage } from "@/features/mail/mail-page" + +vi.mock("@/lib/api", async (importOriginal) => { + const { apiMock } = await import("@/test/api-mock") + return { ...(await importOriginal()), api: apiMock } +}) + +const TS = "2026-09-20T15:00:00.000Z" + +const THREAD = { + id: "t-1", + dir: "in", + from: "clerk@oaklandca.gov", + to: "outreach@civfix.org", + org: "Oakland Public Works", + subject: "Pothole photos", + preview: "Attached", + ts: TS, + unread: false, + status: "replied", + jurisdictionGeoid: null, + reportId: null, +} satisfies MailThreadListItemDTO + +const DETAIL = { + ...THREAD, + messages: [ + { + id: "m-1", + who: "Oakland Public Works", + from: "clerk@oaklandca.gov", + to: "outreach@civfix.org", + dir: "in", + body: "Attached", + ts: TS, + attachments: [ + { key: "https://media.civfix.org/att/1?sig=abc", filename: "pothole.jpg", size: 20480 }, + { key: "javascript:alert(1)", filename: "invoice.html", size: 1024 }, + ], + delivery: "sent", + }, + ], +} satisfies MailThreadDTO + +describe("MailReader attachments", () => { + it("links only http(s) attachment urls, in a new tab without opener or referrer", async () => { + apiMock.getMailStats.mockResolvedValue({ + unread: 0, + threads: 1, + sent: 0, + bounced: 0, + failed: 0, + } satisfies MailStatsResponse) + apiMock.getForwardTemplateDefault.mockResolvedValue({ + subjectTemplate: null, + bodyTemplate: null, + updatedAt: null, + } satisfies GetForwardTemplateDefaultResponse) + apiMock.listInbox.mockResolvedValue({ items: [], nextCursor: null } satisfies InboxListResponse) + apiMock.listMail.mockResolvedValue({ items: [THREAD], nextCursor: null } satisfies MailListResponse) + apiMock.getMailThread.mockResolvedValue(DETAIL) + renderWithQuery() + + const photo = await screen.findByRole("link", { name: /pothole\.jpg/ }) + expect(photo).toHaveAttribute("href", "https://media.civfix.org/att/1?sig=abc") + expect(photo).toHaveAttribute("target", "_blank") + expect(photo).toHaveAttribute("rel", "noopener noreferrer") + + const unsafe = screen.getByText("invoice.html") + expect(detailCard()).toContainElement(unsafe) + expect(unsafe.closest("a")).toBeNull() + expect(unsafe).toHaveTextContent("link unavailable") + expect(photo).not.toHaveTextContent("link unavailable") + expect(document.querySelector('a[href^="javascript:"]')).toBeNull() + }) +}) diff --git a/apps/admin/src/features/mail/mail-page.tsx b/apps/admin/src/features/mail/mail-page.tsx index 62f6e4d..5b8559a 100644 --- a/apps/admin/src/features/mail/mail-page.tsx +++ b/apps/admin/src/features/mail/mail-page.tsx @@ -21,6 +21,8 @@ import { type FilterOption, } from "@/components/shared/page-primitives" import { LoadingState, ErrorState } from "@/components/shared/data-states" +import { usePristineDismiss } from "@/components/shared/backdrop-dismiss" +import { useModalFocus } from "@/components/shared/modal-focus" import { useComposeMail, useForwardTemplateDefault, @@ -35,6 +37,7 @@ import { } from "@/features/mail/use-mail" import { ForwardTemplateModal } from "@/features/mail/forward-template-modal" import { useInboxFeedInfinite, useSetInboxStatus } from "@/features/inbox/use-inbox" +import { AttachmentList } from "@/features/inbox/attachment-chip" import { InboxRow, InboxReader } from "@/features/inbox/inbox-views" import { INBOX_EMPTY_COPY, @@ -116,55 +119,70 @@ function correspondent(sel: MailThreadDTO): string { return sel.to || "—" } -function ComposeModal({ - open, - onClose, - onSend, - pending, -}: { +interface ComposeModalProps { open: boolean onClose: () => void onSend: (input: { to: string; subject: string; body: string }) => void pending: boolean -}) { +} + +function ComposeModal(props: ComposeModalProps) { + if (!props.open) return null + return +} + +function ComposeEditor({ onClose, onSend, pending }: ComposeModalProps) { const [to, setTo] = React.useState("") const [subject, setSubject] = React.useState("") const [body, setBody] = React.useState("") - + const modalRef = useModalFocus(true) + const toRef = React.useRef(null) + const titleId = React.useId() + const toId = React.useId() + const subjectId = React.useId() + const bodyId = React.useId() + + // Focused here rather than with autoFocus: autoFocus lands before useModalFocus records the element + // to restore, so closing would return focus to the dead field instead of the Compose button. React.useEffect(() => { - if (open) { - setTo("") - setSubject("") - setBody("") - } - }, [open]) + toRef.current?.focus() + }, []) + + const backdrop = usePristineDismiss(onClose, to === "" && subject === "" && body === "") - if (!open) return null const canSend = !!to.trim() && !!subject.trim() && !!body.trim() && !pending return ( -
-
e.stopPropagation()}> +
+
-

New message

-
- + setTo(e.target.value)} - autoFocus />
- +
- +