Feature/m2 frontend v2 #8
@@ -26,6 +26,7 @@ import { ProtectedRoute } from './auth/ProtectedRoute'
|
|||||||
import { LoginPage } from './pages/LoginPage'
|
import { LoginPage } from './pages/LoginPage'
|
||||||
import { HomePage } from './pages/HomePage'
|
import { HomePage } from './pages/HomePage'
|
||||||
import { ConfigPage } from './pages/ConfigPage'
|
import { ConfigPage } from './pages/ConfigPage'
|
||||||
|
import { RecordsPage } from './pages/RecordsPage'
|
||||||
import { ChangePasswordPage } from './pages/ChangePasswordPage'
|
import { ChangePasswordPage } from './pages/ChangePasswordPage'
|
||||||
import apiClient from './api/client'
|
import apiClient from './api/client'
|
||||||
import { useQueryClient } from '@tanstack/react-query'
|
import { useQueryClient } from '@tanstack/react-query'
|
||||||
@@ -97,6 +98,14 @@ function AppLayout() {
|
|||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
<Group gap="xs">
|
<Group gap="xs">
|
||||||
|
{/* Records nav link */}
|
||||||
|
<Link
|
||||||
|
to="/records"
|
||||||
|
style={{ textDecoration: 'none', fontSize: '0.9rem' }}
|
||||||
|
title="Records"
|
||||||
|
>
|
||||||
|
Records
|
||||||
|
</Link>
|
||||||
{/* Gear icon nav slot — links to config page (§5#10) */}
|
{/* Gear icon nav slot — links to config page (§5#10) */}
|
||||||
<Link
|
<Link
|
||||||
to="/config"
|
to="/config"
|
||||||
@@ -152,6 +161,7 @@ export default function App() {
|
|||||||
>
|
>
|
||||||
<Route index element={<HomePage />} />
|
<Route index element={<HomePage />} />
|
||||||
<Route path="/config" element={<ConfigPage />} />
|
<Route path="/config" element={<ConfigPage />} />
|
||||||
|
<Route path="/records" element={<RecordsPage />} />
|
||||||
</Route>
|
</Route>
|
||||||
</Routes>
|
</Routes>
|
||||||
</SessionProvider>
|
</SessionProvider>
|
||||||
|
|||||||
@@ -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(<RecordsPage />, { 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()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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<PooRecord | null>(null)
|
||||||
|
const [deleteRecord, setDeleteRecord] = useState<PooRecord | null>(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 (
|
||||||
|
<Center pt="xl" data-testid="poo-loading">
|
||||||
|
<Loader />
|
||||||
|
</Center>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isError || !data) {
|
||||||
|
return (
|
||||||
|
<Alert color="red" data-testid="poo-load-error">
|
||||||
|
Failed to load poo records. Please refresh.
|
||||||
|
</Alert>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const totalPages = data.items.length === PAGE_SIZE ? page + 1 : page
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Stack gap="md">
|
||||||
|
<Group justify="space-between" align="center">
|
||||||
|
<Text size="sm" c="dimmed" data-testid="poo-count">
|
||||||
|
Page {page} · {data.items.length} record{data.items.length !== 1 ? 's' : ''} shown
|
||||||
|
</Text>
|
||||||
|
<Badge variant="outline" color="orange">
|
||||||
|
offset {offset}
|
||||||
|
</Badge>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
<ScrollArea>
|
||||||
|
<Table striped highlightOnHover withTableBorder data-testid="poo-table">
|
||||||
|
<Table.Thead>
|
||||||
|
<Table.Tr>
|
||||||
|
<Table.Th>Timestamp</Table.Th>
|
||||||
|
<Table.Th>Status</Table.Th>
|
||||||
|
<Table.Th>Latitude</Table.Th>
|
||||||
|
<Table.Th>Longitude</Table.Th>
|
||||||
|
<Table.Th style={{ textAlign: 'right' }}>Actions</Table.Th>
|
||||||
|
</Table.Tr>
|
||||||
|
</Table.Thead>
|
||||||
|
<Table.Tbody>
|
||||||
|
{data.items.length === 0 ? (
|
||||||
|
<Table.Tr>
|
||||||
|
<Table.Td colSpan={5}>
|
||||||
|
<Text c="dimmed" ta="center" size="sm">
|
||||||
|
No records.
|
||||||
|
</Text>
|
||||||
|
</Table.Td>
|
||||||
|
</Table.Tr>
|
||||||
|
) : (
|
||||||
|
data.items.map((row) => (
|
||||||
|
<Table.Tr key={row.timestamp} data-testid={`poo-row-${row.timestamp}`}>
|
||||||
|
<Table.Td style={{ whiteSpace: 'nowrap' }}>{row.timestamp}</Table.Td>
|
||||||
|
<Table.Td>{row.status}</Table.Td>
|
||||||
|
<Table.Td>{row.latitude}</Table.Td>
|
||||||
|
<Table.Td>{row.longitude}</Table.Td>
|
||||||
|
<Table.Td>
|
||||||
|
<Group justify="flex-end" gap="xs">
|
||||||
|
<Button
|
||||||
|
size="xs"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => setEditRecord(row)}
|
||||||
|
data-testid={`poo-edit-${row.timestamp}`}
|
||||||
|
>
|
||||||
|
Edit
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="xs"
|
||||||
|
variant="outline"
|
||||||
|
color="red"
|
||||||
|
onClick={() => setDeleteRecord(row)}
|
||||||
|
data-testid={`poo-delete-${row.timestamp}`}
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
</Table.Td>
|
||||||
|
</Table.Tr>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</Table.Tbody>
|
||||||
|
</Table>
|
||||||
|
</ScrollArea>
|
||||||
|
|
||||||
|
{totalPages > 1 && (
|
||||||
|
<Pagination
|
||||||
|
value={page}
|
||||||
|
onChange={setPage}
|
||||||
|
total={totalPages}
|
||||||
|
data-testid="poo-pagination"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Edit modal */}
|
||||||
|
{editRecord && (
|
||||||
|
<EditPooModal
|
||||||
|
record={editRecord}
|
||||||
|
onClose={() => setEditRecord(null)}
|
||||||
|
onSaved={() => setEditRecord(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Delete confirmation modal */}
|
||||||
|
{deleteRecord && (
|
||||||
|
<ConfirmDeleteModal
|
||||||
|
message={`Delete poo record at ${deleteRecord.timestamp}?`}
|
||||||
|
loading={deleteMutation.isPending}
|
||||||
|
onConfirm={handleDeleteConfirm}
|
||||||
|
onCancel={() => setDeleteRecord(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// 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<LocationRecord | null>(null)
|
||||||
|
const [deleteRecord, setDeleteRecord] = useState<LocationRecord | null>(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 (
|
||||||
|
<Center pt="xl" data-testid="location-loading">
|
||||||
|
<Loader />
|
||||||
|
</Center>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isError || !data) {
|
||||||
|
return (
|
||||||
|
<Alert color="red" data-testid="location-load-error">
|
||||||
|
Failed to load location records. Please refresh.
|
||||||
|
</Alert>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const totalPages = data.items.length === PAGE_SIZE ? page + 1 : page
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Stack gap="md">
|
||||||
|
<Group justify="space-between" align="center">
|
||||||
|
<Text size="sm" c="dimmed" data-testid="location-count">
|
||||||
|
Page {page} · {data.items.length} record{data.items.length !== 1 ? 's' : ''} shown
|
||||||
|
</Text>
|
||||||
|
<Badge variant="outline" color="blue">
|
||||||
|
offset {offset}
|
||||||
|
</Badge>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
<ScrollArea>
|
||||||
|
<Table striped highlightOnHover withTableBorder data-testid="location-table">
|
||||||
|
<Table.Thead>
|
||||||
|
<Table.Tr>
|
||||||
|
<Table.Th>Person</Table.Th>
|
||||||
|
<Table.Th>Datetime</Table.Th>
|
||||||
|
<Table.Th>Latitude</Table.Th>
|
||||||
|
<Table.Th>Longitude</Table.Th>
|
||||||
|
<Table.Th>Altitude</Table.Th>
|
||||||
|
<Table.Th style={{ textAlign: 'right' }}>Actions</Table.Th>
|
||||||
|
</Table.Tr>
|
||||||
|
</Table.Thead>
|
||||||
|
<Table.Tbody>
|
||||||
|
{data.items.length === 0 ? (
|
||||||
|
<Table.Tr>
|
||||||
|
<Table.Td colSpan={6}>
|
||||||
|
<Text c="dimmed" ta="center" size="sm">
|
||||||
|
No records.
|
||||||
|
</Text>
|
||||||
|
</Table.Td>
|
||||||
|
</Table.Tr>
|
||||||
|
) : (
|
||||||
|
data.items.map((row) => {
|
||||||
|
const rowKey = `${row.person}__${row.datetime}`
|
||||||
|
return (
|
||||||
|
<Table.Tr key={rowKey} data-testid={`location-row-${rowKey}`}>
|
||||||
|
<Table.Td>{row.person}</Table.Td>
|
||||||
|
<Table.Td style={{ whiteSpace: 'nowrap' }}>{row.datetime}</Table.Td>
|
||||||
|
<Table.Td>{row.latitude}</Table.Td>
|
||||||
|
<Table.Td>{row.longitude}</Table.Td>
|
||||||
|
<Table.Td>{row.altitude ?? '—'}</Table.Td>
|
||||||
|
<Table.Td>
|
||||||
|
<Group justify="flex-end" gap="xs">
|
||||||
|
<Button
|
||||||
|
size="xs"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => setEditRecord(row)}
|
||||||
|
data-testid={`location-edit-${rowKey}`}
|
||||||
|
>
|
||||||
|
Edit
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="xs"
|
||||||
|
variant="outline"
|
||||||
|
color="red"
|
||||||
|
onClick={() => setDeleteRecord(row)}
|
||||||
|
data-testid={`location-delete-${rowKey}`}
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
</Table.Td>
|
||||||
|
</Table.Tr>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</Table.Tbody>
|
||||||
|
</Table>
|
||||||
|
</ScrollArea>
|
||||||
|
|
||||||
|
{totalPages > 1 && (
|
||||||
|
<Pagination
|
||||||
|
value={page}
|
||||||
|
onChange={setPage}
|
||||||
|
total={totalPages}
|
||||||
|
data-testid="location-pagination"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Edit modal */}
|
||||||
|
{editRecord && (
|
||||||
|
<EditLocationModal
|
||||||
|
record={editRecord}
|
||||||
|
onClose={() => setEditRecord(null)}
|
||||||
|
onSaved={() => setEditRecord(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Delete confirmation modal */}
|
||||||
|
{deleteRecord && (
|
||||||
|
<ConfirmDeleteModal
|
||||||
|
message={`Delete location record for ${deleteRecord.person} at ${deleteRecord.datetime}?`}
|
||||||
|
loading={deleteMutation.isPending}
|
||||||
|
onConfirm={handleDeleteConfirm}
|
||||||
|
onCancel={() => setDeleteRecord(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// RecordsPage — top-level
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export function RecordsPage() {
|
||||||
|
return (
|
||||||
|
<Container size="xl" pt="xl" pb="xl" data-testid="records-page">
|
||||||
|
<Title order={2} mb="lg">
|
||||||
|
Records
|
||||||
|
</Title>
|
||||||
|
|
||||||
|
<Tabs defaultValue="poo">
|
||||||
|
<Tabs.List mb="md">
|
||||||
|
<Tabs.Tab value="poo" data-testid="tab-poo">
|
||||||
|
Poo
|
||||||
|
</Tabs.Tab>
|
||||||
|
<Tabs.Tab value="locations" data-testid="tab-locations">
|
||||||
|
Locations
|
||||||
|
</Tabs.Tab>
|
||||||
|
</Tabs.List>
|
||||||
|
|
||||||
|
<Tabs.Panel value="poo">
|
||||||
|
<PooList />
|
||||||
|
</Tabs.Panel>
|
||||||
|
|
||||||
|
<Tabs.Panel value="locations">
|
||||||
|
<LocationList />
|
||||||
|
</Tabs.Panel>
|
||||||
|
</Tabs>
|
||||||
|
</Container>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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'] }),
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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'
|
||||||
Reference in New Issue
Block a user