M2-T10: build records management UI (paginated lists + single-record CRUD)

- reusable src/records/ module: useUpdate/useDelete Poo+Location hooks
  (encodeURIComponent PK, prefix-based query invalidation), EditPooModal,
  EditLocationModal, ConfirmDeleteModal — exported for the map (T09) to reuse
- RecordsPage (/records): paginated poo + location tables (page size 100),
  edit + delete-with-confirm, refresh on success
- query keys ['poo']/['locations'] so map and list invalidations cross-cut
- typed client only; vitest tests
This commit is contained in:
2026-06-13 10:32:02 +02:00
parent 6cc6382515
commit ef7ea6b971
9 changed files with 1442 additions and 0 deletions
@@ -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 (
<Modal opened onClose={onCancel} title="Confirm Delete" size="sm" data-testid="confirm-delete-modal">
<Stack gap="md">
<Text data-testid="confirm-delete-message">{message}</Text>
<Group justify="flex-end" gap="sm">
<Button
variant="default"
onClick={onCancel}
data-testid="confirm-delete-cancel"
>
Cancel
</Button>
<Button
color="red"
loading={loading}
onClick={onConfirm}
data-testid="confirm-delete-confirm"
>
Delete
</Button>
</Group>
</Stack>
</Modal>
)
}
+141
View File
@@ -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<number | string>(record.latitude)
const [longitude, setLongitude] = useState<number | string>(record.longitude)
const [altitude, setAltitude] = useState<number | string>(record.altitude ?? '')
const [error, setError] = useState<string | null>(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 (
<Modal
opened
onClose={onClose}
title="Edit Location Record"
size="sm"
data-testid="edit-location-modal"
>
<form onSubmit={handleSubmit} data-testid="edit-location-form">
<Stack gap="sm">
{/* Composite PK — read-only */}
<Text size="sm" c="dimmed">
<strong>Person (PK):</strong> {record.person}
</Text>
<Text size="sm" c="dimmed">
<strong>Datetime (PK):</strong> {record.datetime}
</Text>
<NumberInput
label="Latitude"
value={latitude}
onChange={(val) => setLatitude(val)}
decimalScale={6}
data-testid="location-latitude-input"
/>
<NumberInput
label="Longitude"
value={longitude}
onChange={(val) => setLongitude(val)}
decimalScale={6}
data-testid="location-longitude-input"
/>
<NumberInput
label="Altitude (optional)"
value={altitude}
onChange={(val) => setAltitude(val)}
decimalScale={2}
placeholder="Leave blank to clear"
data-testid="location-altitude-input"
/>
{error && (
<Alert color="red" data-testid="edit-location-error">
{error}
</Alert>
)}
<Group justify="flex-end" gap="sm">
<Button variant="default" onClick={onClose} data-testid="edit-location-cancel">
Cancel
</Button>
<Button
type="submit"
loading={updateMutation.isPending}
data-testid="edit-location-submit"
>
Save
</Button>
</Group>
</Stack>
</form>
</Modal>
)
}
+130
View File
@@ -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<number | string>(record.latitude)
const [longitude, setLongitude] = useState<number | string>(record.longitude)
const [error, setError] = useState<string | null>(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 (
<Modal
opened
onClose={onClose}
title="Edit Poo Record"
size="sm"
data-testid="edit-poo-modal"
>
<form onSubmit={handleSubmit} data-testid="edit-poo-form">
<Stack gap="sm">
{/* PK — read-only */}
<Text size="sm" c="dimmed">
<strong>Timestamp (PK):</strong> {record.timestamp}
</Text>
<TextInput
label="Status"
value={status}
onChange={(e) => setStatus(e.currentTarget.value)}
data-testid="poo-status-input"
/>
<NumberInput
label="Latitude"
value={latitude}
onChange={(val) => setLatitude(val)}
decimalScale={6}
data-testid="poo-latitude-input"
/>
<NumberInput
label="Longitude"
value={longitude}
onChange={(val) => setLongitude(val)}
decimalScale={6}
data-testid="poo-longitude-input"
/>
{error && (
<Alert color="red" data-testid="edit-poo-error">
{error}
</Alert>
)}
<Group justify="flex-end" gap="sm">
<Button variant="default" onClick={onClose} data-testid="edit-poo-cancel">
Cancel
</Button>
<Button
type="submit"
loading={updateMutation.isPending}
data-testid="edit-poo-submit"
>
Save
</Button>
</Group>
</Stack>
</form>
</Modal>
)
}
+176
View File
@@ -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<paths>({
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<paths>({
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<paths>({
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')
})
})
+98
View File
@@ -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'] }),
})
}
+24
View File
@@ -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'