diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 19766c1..2a3a395 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -26,6 +26,7 @@ import { ProtectedRoute } from './auth/ProtectedRoute' import { LoginPage } from './pages/LoginPage' import { HomePage } from './pages/HomePage' import { ConfigPage } from './pages/ConfigPage' +import { RecordsPage } from './pages/RecordsPage' import { ChangePasswordPage } from './pages/ChangePasswordPage' import apiClient from './api/client' import { useQueryClient } from '@tanstack/react-query' @@ -97,6 +98,14 @@ function AppLayout() { + {/* Records nav link */} + + Records + {/* Gear icon nav slot — links to config page (§5#10) */} } /> } /> + } /> diff --git a/frontend/src/pages/RecordsPage.test.tsx b/frontend/src/pages/RecordsPage.test.tsx new file mode 100644 index 0000000..04053aa --- /dev/null +++ b/frontend/src/pages/RecordsPage.test.tsx @@ -0,0 +1,441 @@ +/** + * Tests for RecordsPage (M2-T10). + * + * Coverage: + * 1. Poo list renders from mocked apiClient GET /api/poo. + * 2. Poo pagination: page 2 requests offset=100. + * 3. Edit poo: clicking Edit opens the modal; form submit calls PATCH with raw (un-encoded) + * PK in the path params (openapi-fetch encodes once; we must not double-encode). + * 4. Delete poo: clicking Delete opens confirmation; confirming calls DELETE and refreshes. + * 5. Location list renders from mocked apiClient GET /api/locations. + * 6. Location pagination: page 2 requests offset=100. + * 7. Edit location: clicking Edit opens modal; form submit calls PATCH with raw PK params. + * 8. Delete location: clicking Delete opens confirmation; confirming calls DELETE. + * 9. Real-encoding regression: stub global fetch; verify actual URL uses single encoding + * (%3A present, %253A absent) for PKs containing colons. + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { screen, waitFor, fireEvent } from '@testing-library/react' +import { renderWithProviders } from '../test-utils' +import { RecordsPage } from './RecordsPage' +import type { LocationRecord } from '../records' + +// --------------------------------------------------------------------------- +// Fixtures +// --------------------------------------------------------------------------- + +const POO_RECORD = { + timestamp: '2026-06-12T10:00:00Z', + status: 'done', + latitude: 51.5, + longitude: -0.1, +} + +const POO_RECORD_2 = { + timestamp: '2026-06-12T11:00:00Z', + status: 'pending', + latitude: 51.6, + longitude: -0.2, +} + +const LOCATION_RECORD: LocationRecord = { + person: 'alice', + datetime: '2026-06-12T09:00:00Z', + latitude: 52.0, + longitude: 1.0, + altitude: 10, +} + +// Build a page of 100 items (all identical except for timestamp offset). +function makePooPage(offset: number) { + return Array.from({ length: 100 }, (_, i) => ({ + timestamp: `2026-06-12T${String(offset + i).padStart(2, '0')}:00:00Z`, + status: 'done', + latitude: 51.5, + longitude: -0.1, + })) +} + +function makeLocationPage(offset: number): LocationRecord[] { + return Array.from({ length: 100 }, (_, i) => ({ + person: 'alice', + datetime: `2026-06-12T${String(offset + i).padStart(2, '0')}:00:00Z`, + latitude: 52.0, + longitude: 1.0, + altitude: null, + })) +} + +// --------------------------------------------------------------------------- +// Mock apiClient +// --------------------------------------------------------------------------- + +const mockGet = vi.fn() +const mockPatch = vi.fn() +const mockDelete = vi.fn() + +vi.mock('../api/client', () => ({ + default: { + GET: (...args: unknown[]) => mockGet(...args), + PATCH: (...args: unknown[]) => mockPatch(...args), + DELETE: (...args: unknown[]) => mockDelete(...args), + }, + ApiError: class ApiError extends Error { + status: number + body: unknown + constructor(status: number, body: unknown) { + super(`API error ${status}`) + this.name = 'ApiError' + this.status = status + this.body = body + } + }, + registerLoginRedirect: vi.fn(), +})) + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function renderRecords() { + return renderWithProviders(, { initialPath: '/records' }) +} + +/** Make GET mock respond based on path. */ +function setupGetMock({ + pooItems = [POO_RECORD], + locationItems = [LOCATION_RECORD], + pooOffset = 0, + locationOffset = 0, +}: { + pooItems?: typeof POO_RECORD[] + locationItems?: typeof LOCATION_RECORD[] + pooOffset?: number + locationOffset?: number +} = {}) { + mockGet.mockImplementation((path: string, opts?: { params?: { query?: { offset?: number } } }) => { + const offset = opts?.params?.query?.offset ?? 0 + if (path === '/api/poo') { + return Promise.resolve({ + data: { items: pooItems, limit: 100, offset: pooOffset || offset }, + response: { status: 200, ok: true }, + }) + } + if (path === '/api/locations') { + return Promise.resolve({ + data: { items: locationItems, limit: 100, offset: locationOffset || offset }, + response: { status: 200, ok: true }, + }) + } + return Promise.resolve({ data: null }) + }) +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe('RecordsPage — Poo tab', () => { + beforeEach(() => { + vi.clearAllMocks() + setupGetMock() + }) + + // ------------------------------------------------------------------------- + // 1. Poo list renders + // ------------------------------------------------------------------------- + + it('renders poo records from GET /api/poo', async () => { + renderRecords() + + await waitFor(() => { + expect(screen.getByTestId('poo-table')).toBeInTheDocument() + }) + + expect(screen.getByText('2026-06-12T10:00:00Z')).toBeInTheDocument() + expect(screen.getByText('done')).toBeInTheDocument() + }) + + // ------------------------------------------------------------------------- + // 2. Poo pagination: page 2 sends offset=100 + // ------------------------------------------------------------------------- + + it('requests offset=100 when page 2 is selected', async () => { + // Return full page to trigger pagination display. + const page1 = makePooPage(0) + setupGetMock({ pooItems: page1 }) + + renderRecords() + + await waitFor(() => { + expect(screen.getByTestId('poo-pagination')).toBeInTheDocument() + }) + + // Click page 2. + const page2Button = screen.getByRole('button', { name: '2' }) + fireEvent.click(page2Button) + + await waitFor(() => { + const allCalls = mockGet.mock.calls.filter((c) => c[0] === '/api/poo') + const page2Call = allCalls.find( + (c) => (c[1]?.params?.query?.offset ?? 0) === 100, + ) + expect(page2Call).toBeDefined() + }) + }) + + // ------------------------------------------------------------------------- + // 3. Edit poo: opens modal, submit calls PATCH with encoded PK + // ------------------------------------------------------------------------- + + it('opens EditPooModal when Edit is clicked; submit calls PATCH with raw PK in path params and correct body', async () => { + mockPatch.mockResolvedValue({ data: POO_RECORD, response: { status: 200, ok: true } }) + renderRecords() + + await waitFor(() => { + expect(screen.getByTestId(`poo-edit-${POO_RECORD.timestamp}`)).toBeInTheDocument() + }) + + fireEvent.click(screen.getByTestId(`poo-edit-${POO_RECORD.timestamp}`)) + + // Modal appears + await waitFor(() => { + expect(screen.getByTestId('edit-poo-modal')).toBeInTheDocument() + }) + + // Change status + const statusInput = screen.getByTestId('poo-status-input') as HTMLInputElement + fireEvent.change(statusInput, { target: { value: 'reviewed' } }) + + // Submit + fireEvent.submit(screen.getByTestId('edit-poo-form')) + + await waitFor(() => { + expect(mockPatch).toHaveBeenCalled() + }) + + const patchCall = mockPatch.mock.calls[0] + expect(patchCall[0]).toBe('/api/poo/{timestamp}') + // PK must be the raw value — openapi-fetch encodes it once; hooks must not pre-encode. + expect(patchCall[1].params.path.timestamp).toBe(POO_RECORD.timestamp) + // Body must include only non-PK fields + expect(patchCall[1].body).toHaveProperty('status') + expect(patchCall[1].body).not.toHaveProperty('timestamp') + }) + + // ------------------------------------------------------------------------- + // 4. Delete poo: confirmation then DELETE called; list refreshes + // ------------------------------------------------------------------------- + + it('shows confirmation modal on Delete click; DELETE is called after confirmation', async () => { + mockDelete.mockResolvedValue({ data: null, response: { status: 204, ok: true } }) + renderRecords() + + await waitFor(() => { + expect(screen.getByTestId(`poo-delete-${POO_RECORD.timestamp}`)).toBeInTheDocument() + }) + + // Click Delete — confirmation modal appears + fireEvent.click(screen.getByTestId(`poo-delete-${POO_RECORD.timestamp}`)) + + await waitFor(() => { + expect(screen.getByTestId('confirm-delete-modal')).toBeInTheDocument() + }) + + // The modal should show a helpful message + expect(screen.getByTestId('confirm-delete-message')).toBeInTheDocument() + + // Cancel first — modal should close + fireEvent.click(screen.getByTestId('confirm-delete-cancel')) + + await waitFor(() => { + expect(screen.queryByTestId('confirm-delete-modal')).not.toBeInTheDocument() + }) + + // Reopen and confirm + fireEvent.click(screen.getByTestId(`poo-delete-${POO_RECORD.timestamp}`)) + + await waitFor(() => { + expect(screen.getByTestId('confirm-delete-confirm')).toBeInTheDocument() + }) + + fireEvent.click(screen.getByTestId('confirm-delete-confirm')) + + await waitFor(() => { + expect(mockDelete).toHaveBeenCalled() + }) + + const deleteCall = mockDelete.mock.calls[0] + expect(deleteCall[0]).toBe('/api/poo/{timestamp}') + // PK must be the raw value — hooks must not pre-encode; openapi-fetch encodes once. + expect(deleteCall[1].params.path.timestamp).toBe(POO_RECORD.timestamp) + }) +}) + +describe('RecordsPage — Locations tab', () => { + beforeEach(() => { + vi.clearAllMocks() + setupGetMock() + }) + + // ------------------------------------------------------------------------- + // 5. Location list renders + // ------------------------------------------------------------------------- + + it('renders location records after switching to Locations tab', async () => { + renderRecords() + + // Switch to Locations tab + await waitFor(() => { + expect(screen.getByTestId('tab-locations')).toBeInTheDocument() + }) + + fireEvent.click(screen.getByTestId('tab-locations')) + + await waitFor(() => { + expect(screen.getByTestId('location-table')).toBeInTheDocument() + }) + + expect(screen.getByText('alice')).toBeInTheDocument() + expect(screen.getByText('2026-06-12T09:00:00Z')).toBeInTheDocument() + }) + + // ------------------------------------------------------------------------- + // 6. Location pagination: page 2 sends offset=100 + // ------------------------------------------------------------------------- + + it('requests offset=100 for locations when page 2 is selected', async () => { + const page1 = makeLocationPage(0) + setupGetMock({ locationItems: page1 }) + + renderRecords() + + // Switch to Locations tab + await waitFor(() => { + expect(screen.getByTestId('tab-locations')).toBeInTheDocument() + }) + fireEvent.click(screen.getByTestId('tab-locations')) + + await waitFor(() => { + expect(screen.getByTestId('location-pagination')).toBeInTheDocument() + }) + + const page2Button = screen.getByRole('button', { name: '2' }) + fireEvent.click(page2Button) + + await waitFor(() => { + const allCalls = mockGet.mock.calls.filter((c) => c[0] === '/api/locations') + const page2Call = allCalls.find( + (c) => (c[1]?.params?.query?.offset ?? 0) === 100, + ) + expect(page2Call).toBeDefined() + }) + }) + + // ------------------------------------------------------------------------- + // 7. Edit location: opens modal, submit calls PATCH with encoded PK + // ------------------------------------------------------------------------- + + it('opens EditLocationModal; submit calls PATCH with raw person+datetime in path params', async () => { + mockPatch.mockResolvedValue({ data: LOCATION_RECORD, response: { status: 200, ok: true } }) + + renderRecords() + + fireEvent.click(screen.getByTestId('tab-locations')) + + const rowKey = `${LOCATION_RECORD.person}__${LOCATION_RECORD.datetime}` + + await waitFor(() => { + expect(screen.getByTestId(`location-edit-${rowKey}`)).toBeInTheDocument() + }) + + fireEvent.click(screen.getByTestId(`location-edit-${rowKey}`)) + + await waitFor(() => { + expect(screen.getByTestId('edit-location-modal')).toBeInTheDocument() + }) + + // PK shown read-only in the modal (may appear more than once in the page: table + modal) + const modalEl = screen.getByTestId('edit-location-modal') + expect(modalEl).toBeInTheDocument() + // 'alice' and datetime appear in modal read-only text + expect(modalEl.textContent).toContain('alice') + expect(modalEl.textContent).toContain('2026-06-12T09:00:00Z') + + // Submit + fireEvent.submit(screen.getByTestId('edit-location-form')) + + await waitFor(() => { + expect(mockPatch).toHaveBeenCalled() + }) + + const patchCall = mockPatch.mock.calls[0] + expect(patchCall[0]).toBe('/api/locations/{person}/{datetime}') + // PKs must be raw — hooks must not pre-encode; openapi-fetch encodes once. + expect(patchCall[1].params.path.person).toBe(LOCATION_RECORD.person) + expect(patchCall[1].params.path.datetime).toBe(LOCATION_RECORD.datetime) + // Body must NOT contain PK fields + expect(patchCall[1].body).not.toHaveProperty('person') + expect(patchCall[1].body).not.toHaveProperty('datetime') + expect(patchCall[1].body).toHaveProperty('latitude') + expect(patchCall[1].body).toHaveProperty('longitude') + }) + + // ------------------------------------------------------------------------- + // 8. Delete location: confirmation then DELETE called + // ------------------------------------------------------------------------- + + it('shows confirmation modal on Delete; DELETE is called with raw PK params', async () => { + mockDelete.mockResolvedValue({ data: null, response: { status: 204, ok: true } }) + + renderRecords() + fireEvent.click(screen.getByTestId('tab-locations')) + + const rowKey = `${LOCATION_RECORD.person}__${LOCATION_RECORD.datetime}` + + await waitFor(() => { + expect(screen.getByTestId(`location-delete-${rowKey}`)).toBeInTheDocument() + }) + + fireEvent.click(screen.getByTestId(`location-delete-${rowKey}`)) + + await waitFor(() => { + expect(screen.getByTestId('confirm-delete-modal')).toBeInTheDocument() + }) + + fireEvent.click(screen.getByTestId('confirm-delete-confirm')) + + await waitFor(() => { + expect(mockDelete).toHaveBeenCalled() + }) + + const deleteCall = mockDelete.mock.calls[0] + expect(deleteCall[0]).toBe('/api/locations/{person}/{datetime}') + // PKs must be raw — hooks must not pre-encode; openapi-fetch encodes once. + expect(deleteCall[1].params.path.person).toBe(LOCATION_RECORD.person) + expect(deleteCall[1].params.path.datetime).toBe(LOCATION_RECORD.datetime) + }) +}) + +// --------------------------------------------------------------------------- +// Additional: multiple poo records with correct timestamps +// --------------------------------------------------------------------------- + +describe('RecordsPage — multiple poo rows', () => { + beforeEach(() => { + vi.clearAllMocks() + setupGetMock({ pooItems: [POO_RECORD, POO_RECORD_2] }) + }) + + it('renders both rows', async () => { + renderRecords() + + await waitFor(() => { + expect(screen.getByTestId('poo-table')).toBeInTheDocument() + }) + + expect(screen.getByText('2026-06-12T10:00:00Z')).toBeInTheDocument() + expect(screen.getByText('2026-06-12T11:00:00Z')).toBeInTheDocument() + }) +}) diff --git a/frontend/src/pages/RecordsPage.tsx b/frontend/src/pages/RecordsPage.tsx new file mode 100644 index 0000000..3a2d5bb --- /dev/null +++ b/frontend/src/pages/RecordsPage.tsx @@ -0,0 +1,375 @@ +/** + * RecordsPage — paginated lists + edit/delete for poo and location records (M2-T10). + * + * - Poo list: GET /api/poo, query key ['poo', {limit, offset}], page size 100. + * - Location list: GET /api/locations, query key ['locations', {limit, offset}], page size 100. + * - Edit and delete use reusable components from src/records/. + * - Delete has a二次确认 modal before calling DELETE. + * - Pagination with Mantine Pagination; next/prev fetches per-page (no full-table pull). + */ + +import { useState } from 'react' +import { useQuery } from '@tanstack/react-query' +import { + Container, + Title, + Table, + Pagination, + Button, + Group, + Tabs, + Text, + Loader, + Center, + Alert, + Stack, + Badge, + ScrollArea, +} from '@mantine/core' +import apiClient from '../api/client' +import { EditPooModal, EditLocationModal, ConfirmDeleteModal } from '../records' +import { useDeletePoo, useDeleteLocation } from '../records' +import type { PooRecord, LocationRecord } from '../records' + +// --------------------------------------------------------------------------- +// Constants +// --------------------------------------------------------------------------- + +const PAGE_SIZE = 100 + +// --------------------------------------------------------------------------- +// Poo list section +// --------------------------------------------------------------------------- + +function PooList() { + const [page, setPage] = useState(1) + const offset = (page - 1) * PAGE_SIZE + + const { data, isLoading, isError } = useQuery({ + queryKey: ['poo', { limit: PAGE_SIZE, offset }], + queryFn: async () => { + const res = await apiClient.GET('/api/poo', { + params: { query: { limit: PAGE_SIZE, offset } }, + }) + return res.data + }, + }) + + const [editRecord, setEditRecord] = useState(null) + const [deleteRecord, setDeleteRecord] = useState(null) + + const deleteMutation = useDeletePoo() + + async function handleDeleteConfirm() { + if (!deleteRecord) return + try { + await deleteMutation.mutateAsync(deleteRecord.timestamp) + setDeleteRecord(null) + } catch { + // Leave the modal open so the user can retry; error display is in the modal loading state. + } + } + + if (isLoading) { + return ( +
+ +
+ ) + } + + if (isError || !data) { + return ( + + Failed to load poo records. Please refresh. + + ) + } + + const totalPages = data.items.length === PAGE_SIZE ? page + 1 : page + + return ( + + + + Page {page} · {data.items.length} record{data.items.length !== 1 ? 's' : ''} shown + + + offset {offset} + + + + + + + + Timestamp + Status + Latitude + Longitude + Actions + + + + {data.items.length === 0 ? ( + + + + No records. + + + + ) : ( + data.items.map((row) => ( + + {row.timestamp} + {row.status} + {row.latitude} + {row.longitude} + + + + + + + + )) + )} + +
+
+ + {totalPages > 1 && ( + + )} + + {/* Edit modal */} + {editRecord && ( + setEditRecord(null)} + onSaved={() => setEditRecord(null)} + /> + )} + + {/* Delete confirmation modal */} + {deleteRecord && ( + setDeleteRecord(null)} + /> + )} +
+ ) +} + +// --------------------------------------------------------------------------- +// Location list section +// --------------------------------------------------------------------------- + +function LocationList() { + const [page, setPage] = useState(1) + const offset = (page - 1) * PAGE_SIZE + + const { data, isLoading, isError } = useQuery({ + queryKey: ['locations', { limit: PAGE_SIZE, offset }], + queryFn: async () => { + const res = await apiClient.GET('/api/locations', { + params: { query: { limit: PAGE_SIZE, offset } }, + }) + return res.data + }, + }) + + const [editRecord, setEditRecord] = useState(null) + const [deleteRecord, setDeleteRecord] = useState(null) + + const deleteMutation = useDeleteLocation() + + async function handleDeleteConfirm() { + if (!deleteRecord) return + try { + await deleteMutation.mutateAsync({ + person: deleteRecord.person, + datetime: deleteRecord.datetime, + }) + setDeleteRecord(null) + } catch { + // Leave modal open. + } + } + + if (isLoading) { + return ( +
+ +
+ ) + } + + if (isError || !data) { + return ( + + Failed to load location records. Please refresh. + + ) + } + + const totalPages = data.items.length === PAGE_SIZE ? page + 1 : page + + return ( + + + + Page {page} · {data.items.length} record{data.items.length !== 1 ? 's' : ''} shown + + + offset {offset} + + + + + + + + Person + Datetime + Latitude + Longitude + Altitude + Actions + + + + {data.items.length === 0 ? ( + + + + No records. + + + + ) : ( + data.items.map((row) => { + const rowKey = `${row.person}__${row.datetime}` + return ( + + {row.person} + {row.datetime} + {row.latitude} + {row.longitude} + {row.altitude ?? '—'} + + + + + + + + ) + }) + )} + +
+
+ + {totalPages > 1 && ( + + )} + + {/* Edit modal */} + {editRecord && ( + setEditRecord(null)} + onSaved={() => setEditRecord(null)} + /> + )} + + {/* Delete confirmation modal */} + {deleteRecord && ( + setDeleteRecord(null)} + /> + )} +
+ ) +} + +// --------------------------------------------------------------------------- +// RecordsPage — top-level +// --------------------------------------------------------------------------- + +export function RecordsPage() { + return ( + + + Records + + + + + + Poo + + + Locations + + + + + + + + + + + + + ) +} diff --git a/frontend/src/records/ConfirmDeleteModal.tsx b/frontend/src/records/ConfirmDeleteModal.tsx new file mode 100644 index 0000000..a95f159 --- /dev/null +++ b/frontend/src/records/ConfirmDeleteModal.tsx @@ -0,0 +1,47 @@ +/** + * ConfirmDeleteModal — generic二次确认 (confirm-before-delete) dialog. + * Used by both poo and location delete flows (M2-T10, reused by T09). + */ + +import { Modal, Stack, Text, Button, Group } from '@mantine/core' + +export interface ConfirmDeleteModalProps { + /** Message shown to the user, e.g. "Delete this poo record?" */ + message: string + /** Whether the delete action is in flight. */ + loading?: boolean + onConfirm: () => void + onCancel: () => void +} + +export function ConfirmDeleteModal({ + message, + loading = false, + onConfirm, + onCancel, +}: ConfirmDeleteModalProps) { + return ( + + + {message} + + + + + + + ) +} diff --git a/frontend/src/records/EditLocationModal.tsx b/frontend/src/records/EditLocationModal.tsx new file mode 100644 index 0000000..c22a538 --- /dev/null +++ b/frontend/src/records/EditLocationModal.tsx @@ -0,0 +1,141 @@ +/** + * EditLocationModal — edit non-PK fields of a location record (M2-T10, reused by T09). + * + * Editable fields: latitude, longitude, altitude. + * Read-only: person + datetime (composite PK). + */ + +import { useState } from 'react' +import { + Modal, + Stack, + NumberInput, + Button, + Group, + Text, + Alert, +} from '@mantine/core' +import { useUpdateLocation } from './hooks' +import type { LocationRecord, LocationUpdateBody } from './hooks' + +export interface EditLocationModalProps { + record: LocationRecord + onClose: () => void + onSaved: () => void +} + +export function EditLocationModal({ record, onClose, onSaved }: EditLocationModalProps) { + const [latitude, setLatitude] = useState(record.latitude) + const [longitude, setLongitude] = useState(record.longitude) + const [altitude, setAltitude] = useState(record.altitude ?? '') + const [error, setError] = useState(null) + + const updateMutation = useUpdateLocation() + + function validate(): string | null { + const lat = Number(latitude) + const lng = Number(longitude) + if (isNaN(lat) || lat < -90 || lat > 90) return 'Latitude must be a number between -90 and 90.' + if (isNaN(lng) || lng < -180 || lng > 180) + return 'Longitude must be a number between -180 and 180.' + // Altitude is optional — blank is fine. + if (altitude !== '' && altitude !== null && isNaN(Number(altitude))) + return 'Altitude must be a number or left blank.' + return null + } + + async function handleSubmit(e: React.FormEvent) { + e.preventDefault() + setError(null) + + const validationError = validate() + if (validationError) { + setError(validationError) + return + } + + const body: LocationUpdateBody = { + latitude: Number(latitude), + longitude: Number(longitude), + altitude: altitude === '' || altitude === null ? null : Number(altitude), + } + + try { + await updateMutation.mutateAsync({ + person: record.person, + datetime: record.datetime, + body, + }) + onSaved() + onClose() + } catch { + setError('Failed to save. Please try again.') + } + } + + return ( + +
+ + {/* Composite PK — read-only */} + + Person (PK): {record.person} + + + Datetime (PK): {record.datetime} + + + setLatitude(val)} + decimalScale={6} + data-testid="location-latitude-input" + /> + + setLongitude(val)} + decimalScale={6} + data-testid="location-longitude-input" + /> + + setAltitude(val)} + decimalScale={2} + placeholder="Leave blank to clear" + data-testid="location-altitude-input" + /> + + {error && ( + + {error} + + )} + + + + + + +
+
+ ) +} diff --git a/frontend/src/records/EditPooModal.tsx b/frontend/src/records/EditPooModal.tsx new file mode 100644 index 0000000..bbbf6a9 --- /dev/null +++ b/frontend/src/records/EditPooModal.tsx @@ -0,0 +1,130 @@ +/** + * EditPooModal — edit non-PK fields of a poo record (M2-T10, reused by T09). + * + * Editable fields: status, latitude, longitude. + * Read-only: timestamp (PK). + */ + +import { useState } from 'react' +import { + Modal, + Stack, + TextInput, + NumberInput, + Button, + Group, + Text, + Alert, +} from '@mantine/core' +import { useUpdatePoo } from './hooks' +import type { PooRecord, PooUpdateBody } from './hooks' + +export interface EditPooModalProps { + record: PooRecord + onClose: () => void + onSaved: () => void +} + +export function EditPooModal({ record, onClose, onSaved }: EditPooModalProps) { + const [status, setStatus] = useState(record.status) + const [latitude, setLatitude] = useState(record.latitude) + const [longitude, setLongitude] = useState(record.longitude) + const [error, setError] = useState(null) + + const updateMutation = useUpdatePoo() + + function validate(): string | null { + const lat = Number(latitude) + const lng = Number(longitude) + if (isNaN(lat) || lat < -90 || lat > 90) return 'Latitude must be a number between -90 and 90.' + if (isNaN(lng) || lng < -180 || lng > 180) + return 'Longitude must be a number between -180 and 180.' + return null + } + + async function handleSubmit(e: React.FormEvent) { + e.preventDefault() + setError(null) + + const validationError = validate() + if (validationError) { + setError(validationError) + return + } + + const body: PooUpdateBody = { + status: status || undefined, + latitude: Number(latitude), + longitude: Number(longitude), + } + + try { + await updateMutation.mutateAsync({ timestamp: record.timestamp, body }) + onSaved() + onClose() + } catch { + setError('Failed to save. Please try again.') + } + } + + return ( + +
+ + {/* PK — read-only */} + + Timestamp (PK): {record.timestamp} + + + setStatus(e.currentTarget.value)} + data-testid="poo-status-input" + /> + + setLatitude(val)} + decimalScale={6} + data-testid="poo-latitude-input" + /> + + setLongitude(val)} + decimalScale={6} + data-testid="poo-longitude-input" + /> + + {error && ( + + {error} + + )} + + + + + + +
+
+ ) +} diff --git a/frontend/src/records/hooks.encoding.test.ts b/frontend/src/records/hooks.encoding.test.ts new file mode 100644 index 0000000..5518455 --- /dev/null +++ b/frontend/src/records/hooks.encoding.test.ts @@ -0,0 +1,176 @@ +/** + * Real-encoding regression test for M2-T10 (REWORK 1). + * + * Motivation: RecordsPage.test.tsx mocks the entire apiClient module, so + * openapi-fetch's defaultPathSerializer never runs in those tests. That means + * the integration between hooks.ts and the real client cannot be verified there. + * + * This file uses two complementary strategies: + * + * A) Direct serializer test — import openapi-fetch's defaultPathSerializer and + * verify that raw PK values (with ':') produce single-encoded URLs (%3A, + * NOT %253A). This is a pure-function test with no network I/O. + * + * B) Live fetch stub — create a real openapi-fetch client instance with a + * custom fetch stub, call the same path that hooks.ts calls (with a raw PK), + * and assert the URL the client constructs contains exactly one level of + * encoding. This exercises the full openapi-fetch → URL-construction path. + * + * Together these prove: + * 1. openapi-fetch encodes raw ':' correctly (as '%3A', once). + * 2. The path template /api/poo/{timestamp} with a raw timestamp produces + * the right URL — and would break if encodeURIComponent were applied first. + */ + +import { describe, it, expect, vi, afterEach } from 'vitest' +import createClient, { defaultPathSerializer } from 'openapi-fetch' +import type { paths } from '../api/schema.d.ts' + +afterEach(() => { + vi.unstubAllGlobals() +}) + +// --------------------------------------------------------------------------- +// A) defaultPathSerializer unit tests +// --------------------------------------------------------------------------- + +describe('openapi-fetch defaultPathSerializer (raw PK → single-encoded URL)', () => { + it('encodes a poo timestamp with colons exactly once', () => { + const template = '/api/poo/{timestamp}' + const rawTs = '2026-06-12T10:00:00Z' + const result = defaultPathSerializer(template, { timestamp: rawTs }) + // Single-encoded colon + expect(result).toContain('%3A') + // Double-encoded colon must NOT appear + expect(result).not.toContain('%253A') + expect(result).toBe('/api/poo/2026-06-12T10%3A00%3A00Z') + }) + + it('encodes location person+datetime with colons exactly once', () => { + const template = '/api/locations/{person}/{datetime}' + const rawDt = '2026-06-12T09:00:00Z' + const result = defaultPathSerializer(template, { person: 'alice', datetime: rawDt }) + expect(result).toContain('%3A') + expect(result).not.toContain('%253A') + expect(result).toBe('/api/locations/alice/2026-06-12T09%3A00%3A00Z') + }) + + it('pre-encoding a PK before passing it causes double-encoding (%253A)', () => { + // This test documents the BUG that was present before REWORK 1: + // hooks.ts was calling encodeURIComponent(timestamp) before passing to + // the client, so defaultPathSerializer would encode it a second time. + const template = '/api/poo/{timestamp}' + const rawTs = '2026-06-12T10:00:00Z' + const preEncoded = encodeURIComponent(rawTs) // what the old hooks.ts did + const result = defaultPathSerializer(template, { timestamp: preEncoded }) + // Double-encoded: '%' → '%25', then '3A' stays → '%253A' + expect(result).toContain('%253A') + // This is WRONG — after fix, hooks must NOT pre-encode. + }) +}) + +// --------------------------------------------------------------------------- +// B) Live fetch-stub test using a real openapi-fetch client instance +// --------------------------------------------------------------------------- + +describe('real openapi-fetch client URL construction (fetch-stub)', () => { + it('DELETE /api/poo/{timestamp} with raw PK produces single-encoded URL', async () => { + const capturedUrls: string[] = [] + const fakeFetch = vi.fn((_input: RequestInfo | URL) => { + const url = + typeof _input === 'string' + ? _input + : _input instanceof URL + ? _input.href + : (_input as Request).url + capturedUrls.push(url) + return Promise.resolve(new Response(null, { status: 204 })) + }) + + // Create a real client with our fake fetch — same config as client.ts + // but with an explicit fetch override so we control the transport. + const testClient = createClient({ + baseUrl: 'http://localhost/', + fetch: fakeFetch as typeof fetch, + }) + + const rawTs = '2026-06-12T10:00:00Z' + await testClient.DELETE('/api/poo/{timestamp}', { + params: { path: { timestamp: rawTs } }, + }) + + expect(fakeFetch).toHaveBeenCalled() + const url = capturedUrls[0] + expect(url).toBeDefined() + + // Single-encoded colon: present + expect(url).toContain('%3A') + // Double-encoded colon: must be absent + expect(url).not.toContain('%253A') + expect(url).toContain('/api/poo/2026-06-12T10%3A00%3A00Z') + }) + + it('DELETE /api/locations/{person}/{datetime} with raw PK produces single-encoded URL', async () => { + const capturedUrls: string[] = [] + const fakeFetch = vi.fn((_input: RequestInfo | URL) => { + const url = + typeof _input === 'string' + ? _input + : _input instanceof URL + ? _input.href + : (_input as Request).url + capturedUrls.push(url) + return Promise.resolve(new Response(null, { status: 204 })) + }) + + const testClient = createClient({ + baseUrl: 'http://localhost/', + fetch: fakeFetch as typeof fetch, + }) + + const rawDt = '2026-06-12T09:00:00Z' + await testClient.DELETE('/api/locations/{person}/{datetime}', { + params: { path: { person: 'alice', datetime: rawDt } }, + }) + + expect(fakeFetch).toHaveBeenCalled() + const url = capturedUrls[0] + expect(url).toBeDefined() + + expect(url).toContain('%3A') + expect(url).not.toContain('%253A') + expect(url).toContain('/api/locations/alice/2026-06-12T09%3A00%3A00Z') + }) + + it('double-encoded PK produces wrong URL — documents the fixed bug', async () => { + // This test shows what the OLD hooks.ts would produce. + // It is intentionally asserting the BAD behavior to document the regression. + const capturedUrls: string[] = [] + const fakeFetch = vi.fn((_input: RequestInfo | URL) => { + const url = + typeof _input === 'string' + ? _input + : _input instanceof URL + ? _input.href + : (_input as Request).url + capturedUrls.push(url) + return Promise.resolve(new Response(null, { status: 204 })) + }) + + const testClient = createClient({ + baseUrl: 'http://localhost/', + fetch: fakeFetch as typeof fetch, + }) + + const rawTs = '2026-06-12T10:00:00Z' + // Simulate what the old hooks.ts did: pre-encode before passing to client + const preEncoded = encodeURIComponent(rawTs) + await testClient.DELETE('/api/poo/{timestamp}', { + params: { path: { timestamp: preEncoded } }, + }) + + const url = capturedUrls[0] + // The OLD code would produce double-encoding (%253A), which caused 404 on the backend + expect(url).toContain('%253A') + }) +}) diff --git a/frontend/src/records/hooks.ts b/frontend/src/records/hooks.ts new file mode 100644 index 0000000..fbe29c7 --- /dev/null +++ b/frontend/src/records/hooks.ts @@ -0,0 +1,98 @@ +/** + * Reusable mutation hooks for poo and location CRUD (M2-T10, reused by T09). + * + * Contract (orchestrator-decisions.md §13): + * - useUpdatePoo / useDeletePoo — PK = timestamp, path /api/poo/{timestamp} + * - useUpdateLocation / useDeleteLocation — PK = person+datetime, path /api/locations/{person}/{datetime} + * - Path params are passed as raw strings; openapi-fetch's defaultPathSerializer + * already calls encodeURIComponent once per simple {param} segment. + * Do NOT call encodeURIComponent here — that would produce double-encoding. + * - On success each hook invalidates the shared query-key prefix ('poo' or 'locations') + * so both list and map views refresh automatically. + */ + +import { useMutation, useQueryClient } from '@tanstack/react-query' +import apiClient from '../api/client' +import type { components } from '../api/schema.d.ts' + +// Re-export record types so T09 can import them from one place. +export type PooRecord = components['schemas']['PooRecord'] +export type LocationRecord = components['schemas']['LocationRecord'] +export type PooUpdateBody = components['schemas']['PooUpdateRequest'] +export type LocationUpdateBody = components['schemas']['LocationUpdateRequest'] + +// --------------------------------------------------------------------------- +// Poo hooks +// --------------------------------------------------------------------------- + +/** Update non-PK fields of a single poo record. */ +export function useUpdatePoo() { + const qc = useQueryClient() + return useMutation({ + mutationFn: ({ timestamp, body }: { timestamp: string; body: PooUpdateBody }) => + apiClient.PATCH('/api/poo/{timestamp}', { + params: { path: { timestamp } }, + body, + }), + onSuccess: () => qc.invalidateQueries({ queryKey: ['poo'] }), + }) +} + +/** Delete a single poo record by its PK (timestamp). */ +export function useDeletePoo() { + const qc = useQueryClient() + return useMutation({ + mutationFn: (timestamp: string) => + apiClient.DELETE('/api/poo/{timestamp}', { + params: { path: { timestamp } }, + }), + onSuccess: () => qc.invalidateQueries({ queryKey: ['poo'] }), + }) +} + +// --------------------------------------------------------------------------- +// Location hooks +// --------------------------------------------------------------------------- + +/** Update non-PK fields of a single location record. */ +export function useUpdateLocation() { + const qc = useQueryClient() + return useMutation({ + mutationFn: ({ + person, + datetime, + body, + }: { + person: string + datetime: string + body: LocationUpdateBody + }) => + apiClient.PATCH('/api/locations/{person}/{datetime}', { + params: { + path: { + person, + datetime, + }, + }, + body, + }), + onSuccess: () => qc.invalidateQueries({ queryKey: ['locations'] }), + }) +} + +/** Delete a single location record by its composite PK (person + datetime). */ +export function useDeleteLocation() { + const qc = useQueryClient() + return useMutation({ + mutationFn: ({ person, datetime }: { person: string; datetime: string }) => + apiClient.DELETE('/api/locations/{person}/{datetime}', { + params: { + path: { + person, + datetime, + }, + }, + }), + onSuccess: () => qc.invalidateQueries({ queryKey: ['locations'] }), + }) +} diff --git a/frontend/src/records/index.ts b/frontend/src/records/index.ts new file mode 100644 index 0000000..7252c69 --- /dev/null +++ b/frontend/src/records/index.ts @@ -0,0 +1,24 @@ +/** + * Public surface of the records module (M2-T10). + * + * T09 (map) imports from here: + * import { useUpdatePoo, useDeletePoo, useUpdateLocation, useDeleteLocation, + * EditPooModal, EditLocationModal, ConfirmDeleteModal } from '../records' + * import type { PooRecord, LocationRecord } from '../records' + */ + +// Hooks +export { useUpdatePoo, useDeletePoo, useUpdateLocation, useDeleteLocation } from './hooks' + +// Types +export type { PooRecord, LocationRecord, PooUpdateBody, LocationUpdateBody } from './hooks' + +// Modals +export { EditPooModal } from './EditPooModal' +export type { EditPooModalProps } from './EditPooModal' + +export { EditLocationModal } from './EditLocationModal' +export type { EditLocationModalProps } from './EditLocationModal' + +export { ConfirmDeleteModal } from './ConfirmDeleteModal' +export type { ConfirmDeleteModalProps } from './ConfirmDeleteModal'