Merge pull request #1065 from nowcommunity/codex/plan-implementation-for-occurrence-detail-page

Add implementation plan for composite-key Occurrence detail page
This commit is contained in:
Kari Lintulaakso
2026-02-27 15:11:32 +02:00
committed by GitHub
32 changed files with 1360 additions and 17 deletions
@@ -0,0 +1,36 @@
import { beforeAll, afterAll, beforeEach, describe, expect, it } from '@jest/globals'
import { login, resetDatabase, resetDatabaseTimeout, send } from '../utils'
import { pool } from '../../utils/db'
describe('Occurrence detail endpoint', () => {
beforeAll(async () => {
await resetDatabase()
}, resetDatabaseTimeout)
beforeEach(async () => {
await login()
})
afterAll(async () => {
await pool.end()
})
it('returns one occurrence by lid and species_id', async () => {
const response = await send<Record<string, unknown>>('occurrence/20920/21052', 'GET')
expect(response.status).toBe(200)
expect(response.body.lid).toBe(20920)
expect(response.body.species_id).toBe(21052)
})
it('returns 404 when pair does not exist', async () => {
const response = await send<Record<string, unknown>>('occurrence/999999/999999', 'GET')
expect(response.status).toBe(404)
expect(response.body).toEqual({ message: 'Occurrence not found' })
})
it('returns 400 when params are invalid', async () => {
const response = await send<Record<string, unknown>>('occurrence/not-a-number/21052', 'GET')
expect(response.status).toBe(400)
expect(response.body).toEqual({ message: 'lid and species_id must be valid integers' })
})
})
+2
View File
@@ -30,6 +30,7 @@ import { requireOneOf } from './middlewares/authorizer'
import { Role } from './../../frontend/src/shared/types'
import { blockWriteRequests } from './middlewares/misc'
import testRouter from './routes/test'
import occurrenceRouter from './routes/occurrence'
const app = express()
@@ -52,6 +53,7 @@ app.use('/email', emailLimiter)
app.use('/user', userRouter)
app.use('/crosssearch', crossSearchRouter)
app.use('/occurrence', occurrenceRouter)
app.use('/locality', localityRouter)
app.use('/locality-species', localitySpeciesRouter)
app.use('/species', speciesRouter)
@@ -0,0 +1,14 @@
import { Request, Response } from 'express'
import { getOccurrenceByCompositeKey, parseOccurrenceRouteParams } from '../services/occurrenceService'
import { fixBigInt } from '../utils/common'
export const getOccurrenceDetail = async (req: Request, res: Response) => {
const { lid, speciesId } = parseOccurrenceRouteParams(req.params.lid, req.params.speciesId)
const occurrence = await getOccurrenceByCompositeKey(lid, speciesId, req.user)
if (!occurrence) {
return res.status(404).json({ message: 'Occurrence not found' })
}
return res.status(200).send(fixBigInt(occurrence))
}
+8
View File
@@ -0,0 +1,8 @@
import { Router } from 'express'
import { getOccurrenceDetail } from '../controllers/occurrenceController'
const router = Router()
router.get('/:lid/:speciesId', getOccurrenceDetail)
export default router
+242
View File
@@ -0,0 +1,242 @@
import { AnyReference, Role, User } from '../../../frontend/src/shared/types'
import { AccessError } from '../middlewares/authorizer'
import { logDb, nowDb } from '../utils/db'
import { buildPersonLookupByInitials, getPersonDisplayName, getPersonFromLookup } from './utils/person'
import { generateOccurrenceDetailSql } from './queries/crossSearchQuery'
const getAllowedLocalities = async (user: User) => {
const usersProjects = await nowDb.now_proj_people.findMany({
where: { initials: user.initials },
select: { pid: true },
})
const projectIDs = Array.from(new Set(usersProjects.map(({ pid }) => pid)))
const localities = await nowDb.now_plr.findMany({
where: { pid: { in: projectIDs } },
select: { lid: true },
})
return Array.from(new Set(localities.map(({ lid }) => lid)))
}
export const parseOccurrenceRouteParams = (lid: string, speciesId: string) => {
const parsedLid = parseInt(lid, 10)
const parsedSpeciesId = parseInt(speciesId, 10)
if (Number.isNaN(parsedLid) || Number.isNaN(parsedSpeciesId)) {
const error = new Error('lid and species_id must be valid integers')
;(error as Error & { status: number }).status = 400
throw error
}
return { lid: parsedLid, speciesId: parsedSpeciesId }
}
type OccurrenceLogRow = Record<string, unknown> & {
pk_data: string
table_name: string
luid?: number
suid?: number
}
const readNumericField = (row: unknown, fieldName: 'luid' | 'suid'): number | null => {
if (typeof row !== 'object' || row === null) return null
const value = (row as Record<string, unknown>)[fieldName]
return typeof value === 'number' ? value : null
}
const collectUniqueIds = (rows: OccurrenceLogRow[], fieldName: 'luid' | 'suid') => {
const ids = new Set<number>()
for (const row of rows) {
const value = readNumericField(row, fieldName)
if (value !== null) ids.add(value)
}
return Array.from(ids)
}
const isOccurrenceLogRow = (value: unknown): value is OccurrenceLogRow => {
if (typeof value !== 'object' || value === null) return false
const row = value as Record<string, unknown>
return (
row.table_name === 'now_ls' &&
typeof row.pk_data === 'string' &&
(typeof row.luid === 'number' || row.luid === undefined) &&
(typeof row.suid === 'number' || row.suid === undefined)
)
}
type OccurrenceUpdate = {
occ_date: Date | null
occ_authorizer: string
occ_coordinator: string
occ_comment: string
references: AnyReference[]
updates: OccurrenceLogRow[]
}
const getOccurrenceUpdates = async (lid: number, speciesId: number) => {
const lidPk = `${lid.toString().length}.${lid};`
const speciesPk = `${speciesId.toString().length}.${speciesId};`
const candidateLogsRaw = await logDb.log.findMany({
where: {
table_name: 'now_ls',
pk_data: { contains: lidPk },
},
})
const nowLsLogs: OccurrenceLogRow[] = candidateLogsRaw.filter(
(logRow): logRow is OccurrenceLogRow => isOccurrenceLogRow(logRow) && logRow.pk_data.includes(speciesPk)
)
const luids = collectUniqueIds(nowLsLogs, 'luid')
const suids = collectUniqueIds(nowLsLogs, 'suid')
const [localityUpdates, speciesUpdates] = await Promise.all([
luids.length
? nowDb.now_lau.findMany({
where: { luid: { in: luids } },
include: {
now_lr: {
include: {
ref_ref: {
include: { ref_authors: true, ref_journal: true },
},
},
},
},
})
: Promise.resolve([]),
suids.length
? nowDb.now_sau.findMany({
where: { suid: { in: suids } },
include: {
now_sr: {
include: {
ref_ref: {
include: { ref_authors: true, ref_journal: true },
},
},
},
},
})
: Promise.resolve([]),
])
const peopleLookup = await buildPersonLookupByInitials([
...localityUpdates.flatMap(update => [update.lau_authorizer, update.lau_coordinator]),
...speciesUpdates.flatMap(update => [update.sau_authorizer, update.sau_coordinator]),
])
const occurrenceUpdates: OccurrenceUpdate[] = [
...localityUpdates.map(update => ({
occ_date: update.lau_date,
occ_authorizer: getPersonDisplayName(
getPersonFromLookup(peopleLookup, update.lau_authorizer),
update.lau_authorizer
),
occ_coordinator: getPersonDisplayName(
getPersonFromLookup(peopleLookup, update.lau_coordinator),
update.lau_coordinator
),
occ_comment: update.lau_comment ?? '',
references: update.now_lr as unknown as AnyReference[],
updates: nowLsLogs.filter(logRow => logRow.luid === update.luid),
})),
...speciesUpdates.map(update => ({
occ_date: update.sau_date,
occ_authorizer: getPersonDisplayName(
getPersonFromLookup(peopleLookup, update.sau_authorizer),
update.sau_authorizer
),
occ_coordinator: getPersonDisplayName(
getPersonFromLookup(peopleLookup, update.sau_coordinator),
update.sau_coordinator
),
occ_comment: update.sau_comment ?? '',
references: update.now_sr as unknown as AnyReference[],
updates: nowLsLogs.filter(logRow => logRow.suid === update.suid),
})),
]
return occurrenceUpdates.sort((a, b) => {
const timeA = a.occ_date ? new Date(a.occ_date).getTime() : 0
const timeB = b.occ_date ? new Date(b.occ_date).getTime() : 0
return timeB - timeA
})
}
export const getOccurrenceByCompositeKey = async (lid: number, speciesId: number, user?: User) => {
const result = await nowDb.$queryRaw<
Array<{
lid: number
species_id: number
loc_status: boolean | null
loc_name: string
country: string
genus_name: string
species_name: string
unique_identifier: string | null
dms_lat: string | null
dms_long: string | null
bfa_max: string | null
bfa_min: string | null
max_age: number | null
min_age: number | null
nis: number | null
pct: number | null
quad: number | null
mni: number | null
qua: string | null
id_status: string | null
orig_entry: string | null
source_name: string | null
body_mass: bigint | null
mesowear: string | null
mw_or_high: number | null
mw_or_low: number | null
mw_cs_sharp: number | null
mw_cs_round: number | null
mw_cs_blunt: number | null
mw_scale_min: number | null
mw_scale_max: number | null
mw_value: number | null
microwear: string | null
dc13_mean: number | null
dc13_n: number | null
dc13_max: number | null
dc13_min: number | null
dc13_stdev: number | null
do18_mean: number | null
do18_n: number | null
do18_max: number | null
do18_min: number | null
do18_stdev: number | null
now_oau: OccurrenceUpdate[]
}>
>(generateOccurrenceDetailSql(lid, speciesId))
const occurrence = result[0]
if (!occurrence) return null
if (occurrence.loc_status) {
if (!user) throw new AccessError()
if (![Role.Admin, Role.EditUnrestricted].includes(user.role)) {
const allowedLocalities = await getAllowedLocalities(user)
if (!allowedLocalities.includes(lid)) throw new AccessError()
}
}
const occurrenceUpdates = await getOccurrenceUpdates(lid, speciesId)
return {
...occurrence,
now_oau: occurrenceUpdates,
}
}
@@ -28,6 +28,59 @@ const generateWhereClause = (showAll: boolean, allowedLocalities: Array<number>,
else return Prisma.sql`WHERE ${userCheck} AND ${columnFilterCheck}`
}
export const generateOccurrenceDetailSql = (lid: number, speciesId: number) => {
return Prisma.sql`
SELECT
now_ls.lid,
now_ls.species_id,
now_ls.nis,
now_ls.pct,
now_ls.quad,
now_ls.mni,
now_ls.qua,
now_ls.id_status,
now_ls.orig_entry,
now_ls.source_name,
now_ls.body_mass,
now_ls.mesowear,
now_ls.mw_or_high,
now_ls.mw_or_low,
now_ls.mw_cs_sharp,
now_ls.mw_cs_round,
now_ls.mw_cs_blunt,
now_ls.mw_scale_min,
now_ls.mw_scale_max,
now_ls.mw_value,
now_ls.microwear,
now_ls.dc13_mean,
now_ls.dc13_n,
now_ls.dc13_max,
now_ls.dc13_min,
now_ls.dc13_stdev,
now_ls.do18_mean,
now_ls.do18_n,
now_ls.do18_max,
now_ls.do18_min,
now_ls.do18_stdev,
now_loc.loc_status,
now_loc.loc_name,
now_loc.country,
now_loc.dms_lat,
now_loc.dms_long,
now_loc.max_age,
now_loc.min_age,
now_loc.bfa_max,
now_loc.bfa_min,
com_species.genus_name,
com_species.species_name,
com_species.unique_identifier
FROM now_ls
INNER JOIN now_loc ON now_ls.lid = now_loc.lid
INNER JOIN com_species ON now_ls.species_id = com_species.species_id
WHERE now_ls.lid = ${lid} AND now_ls.species_id = ${speciesId}
LIMIT 1
`
}
// change name
export const generateCrossSearchSql = (
showAll: boolean,
@@ -0,0 +1,56 @@
import { beforeEach, describe, expect, it, jest } from '@jest/globals'
import { parseOccurrenceRouteParams, getOccurrenceByCompositeKey } from '../../services/occurrenceService'
import { nowDb } from '../../utils/db'
import { Role } from '../../../../frontend/src/shared/types'
import { AccessError } from '../../middlewares/authorizer'
jest.mock('../../utils/db', () => ({
nowDb: {
$queryRaw: jest.fn(),
now_proj_people: { findMany: jest.fn() },
now_plr: { findMany: jest.fn() },
},
}))
const mockedNowDb = jest.mocked(nowDb)
describe('parseOccurrenceRouteParams', () => {
it('parses integer route params', () => {
expect(parseOccurrenceRouteParams('10', '20')).toEqual({ lid: 10, speciesId: 20 })
})
it('throws 400 error for invalid params', () => {
expect(() => parseOccurrenceRouteParams('10', 'x')).toThrow('lid and species_id must be valid integers')
try {
parseOccurrenceRouteParams('10', 'x')
} catch (error) {
expect((error as Error & { status?: number }).status).toBe(400)
}
})
})
describe('getOccurrenceByCompositeKey', () => {
beforeEach(() => {
mockedNowDb.$queryRaw.mockReset()
mockedNowDb.now_proj_people.findMany.mockReset()
mockedNowDb.now_plr.findMany.mockReset()
})
it('returns null when occurrence does not exist', async () => {
mockedNowDb.$queryRaw.mockResolvedValue([])
await expect(getOccurrenceByCompositeKey(1, 2)).resolves.toBeNull()
})
it('throws AccessError for private occurrence when user cannot access locality', async () => {
mockedNowDb.$queryRaw.mockResolvedValue([
{ lid: 1, species_id: 2, loc_status: true, loc_name: 'loc', country: 'x', genus_name: 'g', species_name: 's' },
])
mockedNowDb.now_proj_people.findMany.mockResolvedValue([{ pid: 1, initials: 'AA' }])
mockedNowDb.now_plr.findMany.mockResolvedValue([{ lid: 999, pid: 1 }])
await expect(
getOccurrenceByCompositeKey(1, 2, { username: 'u', role: Role.EditRestricted, userId: 1, initials: 'AA' })
).rejects.toBeInstanceOf(AccessError)
})
})
+240
View File
@@ -0,0 +1,240 @@
# Feature Plan: Occurrence Detail Page (now_ls)
## 1️⃣ Assumptions & Scope
- The current `/occurrence` experience is backed by `CrossSearchTable` and reuses `LocalityDetails` for detail routing (`/occurrence/:id`), so this feature introduces a dedicated Occurrence detail view instead of locality detail reuse.
- Occurrence identity must be **composite** (`lid` + `species_id`) because `now_ls` has no primary key; we will not change DB schema or add migrations.
- Backend and frontend contracts should remain additive and backward compatible; existing Locality/Species detail pages and list/table flows must continue to work.
- Required tabs should map to meaningful `now_ls` columns/groups, and an **Updates** tab should be present as a placeholder shell only (no update-log implementation yet), matching existing tab UX patterns.
- Linking behavior from occurrence listings should open the new Occurrence detail route using `lid` + `species_id` key, while preserving existing table filter/sort/pagination query params for return navigation.
- Authorization model remains unchanged: read access follows current authenticated behavior; edit affordances follow existing role checks (Admin/EditUnrestricted/EditRestricted) already used around detail pages.
- No database structure changes, no Prisma migration files, and no table/model renames.
## 2️⃣ High-Level Plan
1. **Occurrence identity and routing design**
- Define a stable URL shape for composite keys (e.g. `/occurrence/:lid/:speciesId`), and retain list URL compatibility (`/occurrence?...`).
- Update route wiring so occurrence detail resolves to a new Occurrence detail component instead of `LocalityDetails`.
2. **Backend read endpoint for one occurrence (composite key)**
- Add/extend an endpoint to fetch one `now_ls` row by `lid` + `species_id` with required joins (species/locality context fields needed for header + tabs).
- Reuse existing services/query utilities where possible to stay DRY and avoid duplicated SQL mapping.
3. **Frontend data model + API client wiring**
- Add shared/frontend type(s) for occurrence detail payload shaped around `now_ls` + related display fields.
- Add query hook/client call for fetching one occurrence by composite key.
4. **Build Occurrence detail feature component (DRY with existing DetailView patterns)**
- Create `OccurrenceDetails` component mirroring Locality/Species detail architecture (`DetailView`, tab config array, validator wiring).
- Group tabs by `now_ls` domain columns (e.g., core occurrence info, microwear/mesowear, measurements/notes as available in schema).
- Add **Updates** tab placeholder using standard tab container/panel only, no data implementation yet.
5. **Linking from occurrence list to detail**
- Ensure row actions/links in occurrence table navigate with composite key (`lid`, `species_id`) rather than locality-only id.
- Preserve table query state (`columnfilters`, `sorting`, `pagination`) in return navigation state.
6. **Permissions + UX parity**
- Keep edit/delete capability checks consistent with existing role policy; if detail is read-only initially, explicitly disable write actions.
- Keep tab deep-linking behavior (`?tab=n`) consistent with `useSyncTabSearch` and existing detail pages.
7. **Validation, testing, docs**
- Add/adjust backend integration tests for composite-key fetch and error paths.
- Add frontend tests for route parsing, tab rendering (including Updates placeholder), and occurrence row-to-detail navigation.
- Update docs/changelog notes for new occurrence detail page and composite key route contract.
## 3️⃣ Tasks (JSON)
```json
[
{
"id": "T1",
"title": "Define composite-key occurrence route and page wiring",
"summary": "Introduce occurrence detail routing that accepts lid + species_id and maps to a dedicated OccurrenceDetails component instead of LocalityDetails.",
"app": "frontend",
"files_touched": [
"frontend/src/router/index.tsx",
"frontend/src/components/pages.tsx",
"frontend/src/components/Page.tsx"
],
"migrations": false,
"permissions": [
"Accessible to logged-in users who can view occurrence list",
"No role escalation; existing page-level role checks remain intact"
],
"acceptance_criteria": [
"Navigating to /occurrence/<lid>/<species_id> opens Occurrence detail page",
"Navigating to /occurrence?columnfilters=[]&sorting=[]&pagination={...} still opens occurrence list",
"Legacy /occurrence/<id> behavior is either redirected safely or handled explicitly"
],
"test_plan": [
"Add router-level test for occurrence composite route",
"Add regression test for occurrence list route query parsing"
],
"estimate_hours": 2.0,
"priority": "high"
},
{
"id": "T2",
"title": "Add backend occurrence-detail read endpoint by lid + species_id",
"summary": "Implement composite-key lookup for a single now_ls occurrence with required joins/shape for detail tabs and header metadata.",
"app": "backend",
"files_touched": [
"backend/src/routes/occurrence.ts",
"backend/src/controllers/occurrenceController.ts",
"backend/src/services/occurrenceService.ts",
"backend/src/services/queries/crossSearchQuery.ts"
],
"migrations": false,
"permissions": [
"Available to authorized readers under existing auth middleware",
"Write permissions unchanged (no new mutation endpoint)"
],
"acceptance_criteria": [
"GET endpoint returns one occurrence by exact lid + species_id",
"Missing pair returns 404 with structured error payload",
"Invalid keys return 400 with validation message"
],
"test_plan": [
"Add backend integration tests for success/404/400 cases",
"Add unit tests for service query parameter handling"
],
"estimate_hours": 3.5,
"priority": "high"
},
{
"id": "T3",
"title": "Create occurrence detail types and data hook",
"summary": "Add shared/frontend types and query hook/API client for composite-key occurrence detail retrieval.",
"app": "frontend",
"files_touched": [
"frontend/src/shared/types/data.ts",
"frontend/src/redux/services/api.ts",
"frontend/src/hooks/**"
],
"migrations": false,
"permissions": [
"Read-only data retrieval inherits existing auth token handling"
],
"acceptance_criteria": [
"Hook accepts lid + species_id and returns typed detail payload",
"Loading/error states align with existing detail pages"
],
"test_plan": [
"Add hook/API tests for correct URL and response mapping",
"Type-check verifies no implicit any in detail payload usage"
],
"estimate_hours": 2.0,
"priority": "high"
},
{
"id": "T4",
"title": "Implement OccurrenceDetails with now_ls-based tabs and Updates placeholder",
"summary": "Build dedicated detail UI using shared DetailView primitives; include required tabs from now_ls columns and a standard placeholder Updates tab (no updates logic yet).",
"app": "frontend",
"files_touched": [
"frontend/src/components/Occurrence/OccurrenceDetails.tsx",
"frontend/src/components/Occurrence/Tabs/*.tsx",
"frontend/src/components/DetailView/common/UpdateTab.tsx"
],
"migrations": false,
"permissions": [
"Edit controls shown only for roles already allowed by policy",
"If mutations are not implemented, controls remain disabled/hidden"
],
"acceptance_criteria": [
"Detail page renders tab set derived from now_ls fields",
"Updates tab is visible and clearly marked placeholder",
"Tab deep-linking works via ?tab=<index>"
],
"test_plan": [
"Add component tests for tab labels/content and placeholder tab",
"Add test for out-of-range tab param fallback behavior"
],
"estimate_hours": 4.0,
"priority": "high"
},
{
"id": "T5",
"title": "Wire occurrence table row navigation to composite-key detail",
"summary": "Update occurrence list row action/linking so each row opens its specific occurrence detail using lid + species_id key.",
"app": "frontend",
"files_touched": [
"frontend/src/components/CrossSearch/CrossSearchTable.tsx",
"frontend/src/components/TableView/TableView.tsx",
"frontend/src/hooks/useReturnNavigation.ts"
],
"migrations": false,
"permissions": [
"Row visibility restrictions remain unchanged",
"Navigation does not bypass protected routes"
],
"acceptance_criteria": [
"Clicking an occurrence row opens /occurrence/<lid>/<species_id>",
"Return button restores prior list URL including filter/sort/pagination query",
"No regression in locality/species table row navigation"
],
"test_plan": [
"Add frontend tests asserting navigation target path composition",
"Regression tests for return navigation stack behavior"
],
"estimate_hours": 2.5,
"priority": "high"
},
{
"id": "T6",
"title": "Quality gate, docs, and rollout safeguards",
"summary": "Run lint/type/tests, document route contract and placeholder status, and ensure feature is safely reviewable/deployable.",
"app": "fullstack",
"files_touched": [
"README.md",
"frontend/docs/routing.md",
"frontend/tests/**",
"backend/src/api-tests/**"
],
"migrations": false,
"permissions": [
"Verify role-based UI states for read/edit users"
],
"acceptance_criteria": [
"Lint + type-check pass in affected scopes",
"New/updated tests pass",
"Docs mention composite key route and Updates tab placeholder"
],
"test_plan": [
"npm run lint:frontend && npm run lint:backend",
"npm run tsc:frontend && npm run tsc:backend",
"Run targeted frontend/backend test suites for occurrence detail"
],
"estimate_hours": 2.0,
"priority": "medium"
}
]
```
## 4️⃣ Risks & Mitigations
- **Auth Failures**: New detail route could miss existing guard expectations.
**Mitigation**: Keep route under existing authenticated shell and add explicit unauthorized tests for restricted edit actions.
- **Composite Key Ambiguity**: Using only `lid` could open wrong record when multiple species share locality.
**Mitigation**: Enforce `lid + species_id` in route, API contract, and row navigation helpers.
- **Performance**: Occurrence detail joins may fetch unnecessary fields.
**Mitigation**: Select only tab-needed columns; defer heavy/related datasets to lazy queries if needed.
- **Error Handling**: Bad URL params may produce opaque failures.
**Mitigation**: Add input validation and consistent 400/404 JSON errors; render friendly UI fallback.
- **Security**: Query construction risk if custom SQL is touched.
**Mitigation**: Keep Prisma/parameterized query patterns and avoid string interpolation for user params.
- **Rollback**: Route change could disrupt existing deep links.
**Mitigation**: Add compatibility redirect/fallback for legacy paths and keep change isolated behind clean commits.
## 5️⃣ Out of Scope
- Any database schema/table/index/primary-key changes for `now_ls`.
- Implementing actual Updates tab data loading/editing/audit timeline logic.
- Broad UI redesign beyond adding occurrence detail page and required tabs.
- Refactoring unrelated list/detail pages (Locality, Species, etc.) outside minimal DRY extraction.
- Non-feature performance tuning unrelated to occurrence detail fetch/render.
## 6️⃣ Definition of Done ✅
- [ ] All acceptance criteria in tasks T1–T6 are satisfied.
- [ ] Occurrence detail is accessible via composite key route (`lid` + `species_id`).
- [ ] Required `now_ls`-based tabs are implemented and Updates tab placeholder is present.
- [ ] No DB migrations/schema changes are introduced.
- [ ] Unit/integration/frontend route tests for new behavior pass.
- [ ] Linting and TypeScript checks pass in affected scopes.
- [ ] Role-based read/edit behavior is verified unchanged.
- [ ] Documentation is updated with new route contract and placeholder note.
@@ -821,6 +821,7 @@ export const CrossSearchTable = ({ selectorFn }: { selectorFn?: (newObject: Cros
title={occurrenceLabels.crossSearchTitle}
selectorFn={selectorFn}
checkRowRestriction={checkRowRestriction}
getDetailPath={row => `/occurrence/${row.lid_now_loc}/${row.species_id_com_species}`}
idFieldName="lid_now_loc"
columns={columns}
isFetching={isFetching}
@@ -5,6 +5,8 @@ import { useDetailContext } from '../Context/DetailContext'
import RemoveCircleOutlineIcon from '@mui/icons-material/RemoveCircleOutline'
import AddCircleOutlineIcon from '@mui/icons-material/AddCircleOutline'
import { useEffect } from 'react'
import { useLocation, useNavigate, useSearchParams } from 'react-router-dom'
import { usePageContext } from '@/components/Page'
import { checkFieldErrors } from './checkFieldErrors'
import { ActionComponent } from '@/components/TableView/ActionComponent'
import { DetailTabTable } from './DetailTabTable'
@@ -30,6 +32,7 @@ export const EditableTable = <
enableAdvancedTableControls = false,
idFieldName,
url,
getDetailPath,
}: {
tableData?: Array<T> | null
editTableData?: Array<EditDataType<T>> | null
@@ -41,9 +44,15 @@ export const EditableTable = <
enableAdvancedTableControls?: boolean
idFieldName?: keyof T
url?: string
getDetailPath?: (row: T) => string
}) => {
const { editData, setEditData, mode, data, validator, fieldsWithErrors, setFieldsWithErrors } =
useDetailContext<ParentType>()
const navigate = useNavigate()
const location = useLocation()
const [searchParams] = useSearchParams()
const { previousTableUrls, setPreviousTableUrls } = usePageContext()
const errorObject = validator(editData, field)
useEffect(() => {
@@ -89,8 +98,13 @@ export const EditableTable = <
)
}
const resolveDetailPath = (row: T) => {
if (getDetailPath) return getDetailPath(row)
return `/${url}/${String(row[idFieldName as keyof T] ?? '')}`
}
const linkToDetails = ({ row }: { row: MRT_Row<T> }) => {
if (idFieldName && url) return <ActionComponent {...{ row, idFieldName, url }} />
if (idFieldName && url) return <ActionComponent {...{ row, idFieldName, url, getDetailPath }} />
return null // code shouldn't get here!
}
@@ -138,7 +152,18 @@ export const EditableTable = <
enableRowActions={Boolean(resolveRenderRowActions())}
renderRowActions={resolveRenderRowActions()}
muiTableBodyRowProps={({ row }: { row: MRT_Row<T> }) => ({
sx: { backgroundColor: rowStateToColor(row.original.rowState) },
onClick: () => {
if (mode.read && idFieldName && url && getDetailPath) {
setPreviousTableUrls([...previousTableUrls, `${location.pathname}?tab=${searchParams.get('tab')}`])
navigate(resolveDetailPath(row.original), {
state: { returnTo: `${location.pathname}${location.search}` },
})
}
},
sx: {
backgroundColor: rowStateToColor(row.original.rowState),
cursor: mode.read && idFieldName && url && getDetailPath ? 'pointer' : undefined,
},
})}
/>
)
@@ -0,0 +1,28 @@
import { ReactNode } from 'react'
import { describe, expect, it, jest } from '@jest/globals'
import { render, screen } from '@testing-library/react'
import { UpdateTab } from './UpdateTab'
jest.mock('@/components/DetailView/Context/DetailContext', () => ({
useDetailContext: () => ({ data: {} }),
}))
jest.mock('./EditingModal', () => ({
EditingModal: ({ children }: { children: ReactNode }) => <div>{children}</div>,
}))
jest.mock('./SimpleTable', () => ({
SimpleTable: () => <div data-testid="simple-table" />,
}))
jest.mock('./ReferenceList', () => ({
ReferenceList: () => <div data-testid="reference-list" />,
}))
describe('UpdateTab', () => {
it('renders placeholder message when placeholder mode is enabled', () => {
render(<UpdateTab prefix="occ" placeholderMessage="Updates tab placeholder. Implementation is pending." />)
expect(screen.getByText('Updates tab placeholder. Implementation is pending.')).toBeTruthy()
})
})
@@ -16,13 +16,25 @@ export const UpdateTab = <T, UpdateType extends MRT_RowData & { updates: UpdateL
prefix,
refFieldName,
updatesFieldName,
placeholderMessage,
}: {
prefix: string
refFieldName: keyof UpdateType
updatesFieldName: keyof T
refFieldName?: keyof UpdateType
updatesFieldName?: keyof T
placeholderMessage?: string
}) => {
const { data } = useDetailContext<T>()
if (placeholderMessage) {
return (
<Grouped title="Updates">
<Box>{placeholderMessage}</Box>
</Grouped>
)
}
if (!refFieldName || !updatesFieldName) return null
const columns: MRT_ColumnDef<UpdateType>[] = [
{
accessorKey: `${prefix}_date`,
@@ -0,0 +1,89 @@
import { CircularProgress } from '@mui/material'
import { useParams } from 'react-router-dom'
import { DetailView, TabType } from '@/components/DetailView/DetailView'
import { UpdateTab } from '@/components/DetailView/common/UpdateTab'
import { OccurrenceCoreTab } from './Tabs/OccurrenceCoreTab'
import { OccurrenceWearTab } from './Tabs/OccurrenceWearTab'
import { OccurrenceIsotopeTab } from './Tabs/OccurrenceIsotopeTab'
import { useOccurrenceDetails } from '@/hooks/useOccurrenceDetails'
import { OccurrenceDetailsType } from '@/shared/types'
import { ValidationObject } from '@/shared/validators/validator'
const validateOccurrence = (): ValidationObject => ({ error: null, name: '' })
const emptyOccurrence: OccurrenceDetailsType = {
lid: 0,
species_id: 0,
loc_status: null,
loc_name: '',
country: '',
genus_name: '',
species_name: '',
unique_identifier: null,
dms_lat: null,
dms_long: null,
bfa_max: null,
bfa_min: null,
max_age: null,
min_age: null,
nis: null,
pct: null,
quad: null,
mni: null,
qua: null,
id_status: null,
orig_entry: null,
source_name: null,
body_mass: null,
mesowear: null,
mw_or_high: null,
mw_or_low: null,
mw_cs_sharp: null,
mw_cs_round: null,
mw_cs_blunt: null,
mw_scale_min: null,
mw_scale_max: null,
mw_value: null,
microwear: null,
dc13_mean: null,
dc13_n: null,
dc13_max: null,
dc13_min: null,
dc13_stdev: null,
do18_mean: null,
do18_n: null,
do18_max: null,
do18_min: null,
do18_stdev: null,
now_oau: [],
}
export const OccurrenceDetails = () => {
const { lid, speciesId } = useParams()
const parsedLid = lid ? parseInt(lid, 10) : null
const parsedSpeciesId = speciesId ? parseInt(speciesId, 10) : null
const { occurrence, isLoading, isError } = useOccurrenceDetails(parsedLid, parsedSpeciesId)
if (isError) return <div>Error loading occurrence data</div>
if (isLoading || !occurrence) return <CircularProgress />
document.title = `Occurrence - ${occurrence.lid}/${occurrence.species_id}`
const tabs: TabType[] = [
{ title: 'Core', content: <OccurrenceCoreTab /> },
{ title: 'Wear', content: <OccurrenceWearTab /> },
{ title: 'Isotopes', content: <OccurrenceIsotopeTab /> },
{
title: 'Updates',
content: <UpdateTab prefix="occ" refFieldName="references" updatesFieldName="now_oau" />,
},
]
return (
<DetailView<OccurrenceDetailsType>
tabs={tabs}
data={occurrence ?? emptyOccurrence}
validator={validateOccurrence}
/>
)
}
@@ -0,0 +1,55 @@
import { useDetailContext } from '@/components/DetailView/Context/DetailContext'
import { ArrayFrame, HalfFrames } from '@/components/DetailView/common/tabLayoutHelpers'
import { OccurrenceDetailsType } from '@/shared/types'
const toText = (value: string | number | null) => (value === null || value === '' ? '-' : String(value))
const quantityLabel = (value: string | null) => {
const quantityMap: Record<string, string> = {
a: 'Abundant',
c: 'Common',
r: 'Rare',
v: 'Very rare',
}
if (!value) return '-'
return quantityMap[value] ?? value
}
export const OccurrenceCoreTab = () => {
const { data } = useDetailContext<OccurrenceDetailsType>()
return (
<>
<HalfFrames>
{[
<ArrayFrame
key="identification"
title="Identification"
array={[
['Locality ID', toText(data.lid)],
['Species ID', toText(data.species_id)],
['Genus', toText(data.genus_name)],
['Species', toText(data.species_name)],
['ID status', toText(data.id_status)],
['Original entry', toText(data.orig_entry)],
['Source name', toText(data.source_name)],
]}
/>,
<ArrayFrame
key="counts"
title="Occurrence counts"
array={[
['NISP', toText(data.nis)],
['Percent', toText(data.pct)],
['Quadrate', toText(data.quad)],
['MNI', toText(data.mni)],
['Quantity', quantityLabel(data.qua)],
]}
/>,
]}
</HalfFrames>
<ArrayFrame array={[['Body mass', toText(data.body_mass)]]} title="Size" />
</>
)
}
@@ -0,0 +1,38 @@
import { useDetailContext } from '@/components/DetailView/Context/DetailContext'
import { ArrayFrame, HalfFrames } from '@/components/DetailView/common/tabLayoutHelpers'
import { OccurrenceDetailsType } from '@/shared/types'
const toText = (value: number | null) => (value === null ? '-' : String(value))
export const OccurrenceIsotopeTab = () => {
const { data } = useDetailContext<OccurrenceDetailsType>()
return (
<HalfFrames>
{[
<ArrayFrame
key="dc13"
title="δ13C"
array={[
['Mean', toText(data.dc13_mean)],
['N', toText(data.dc13_n)],
['Max', toText(data.dc13_max)],
['Min', toText(data.dc13_min)],
['Stdev', toText(data.dc13_stdev)],
]}
/>,
<ArrayFrame
key="do18"
title="δ18O"
array={[
['Mean', toText(data.do18_mean)],
['N', toText(data.do18_n)],
['Max', toText(data.do18_max)],
['Min', toText(data.do18_min)],
['Stdev', toText(data.do18_stdev)],
]}
/>,
]}
</HalfFrames>
)
}
@@ -0,0 +1,38 @@
import { useDetailContext } from '@/components/DetailView/Context/DetailContext'
import { ArrayFrame, HalfFrames } from '@/components/DetailView/common/tabLayoutHelpers'
import { OccurrenceDetailsType } from '@/shared/types'
const toText = (value: string | number | null) => (value === null || value === '' ? '-' : String(value))
export const OccurrenceWearTab = () => {
const { data } = useDetailContext<OccurrenceDetailsType>()
return (
<HalfFrames>
{[
<ArrayFrame
key="mesowear"
title="Mesowear"
array={[
['Mesowear', toText(data.mesowear)],
['MW OR High', toText(data.mw_or_high)],
['MW OR Low', toText(data.mw_or_low)],
['MW CS Sharp', toText(data.mw_cs_sharp)],
['MW CS Round', toText(data.mw_cs_round)],
['MW CS Blunt', toText(data.mw_cs_blunt)],
]}
/>,
<ArrayFrame
key="microwear"
title="Microwear"
array={[
['Microwear', toText(data.microwear)],
['MW scale min', toText(data.mw_scale_min)],
['MW scale max', toText(data.mw_scale_max)],
['MW value', toText(data.mw_value)],
]}
/>,
]}
</HalfFrames>
)
}
@@ -0,0 +1,60 @@
import { beforeEach, describe, expect, it, jest } from '@jest/globals'
import { render } from '@testing-library/react'
import type { ReactNode } from 'react'
import { OccurrenceCoreTab } from '../OccurrenceCoreTab'
import { useDetailContext } from '@/components/DetailView/Context/DetailContext'
jest.mock('@/components/DetailView/Context/DetailContext', () => ({
useDetailContext: jest.fn(),
}))
const capturedArrayFrameProps: Array<{ title: string; array: Array<Array<string>> }> = []
jest.mock('@/components/DetailView/common/tabLayoutHelpers', () => ({
HalfFrames: ({ children }: { children: ReactNode }) => <div>{children}</div>,
ArrayFrame: (props: { title: string; array: Array<Array<string>> }) => {
capturedArrayFrameProps.push(props)
return <div data-testid="array-frame" />
},
}))
const mockUseDetailContext = useDetailContext as jest.MockedFunction<typeof useDetailContext>
describe('OccurrenceCoreTab', () => {
beforeEach(() => {
capturedArrayFrameProps.length = 0
mockUseDetailContext.mockReturnValue({
data: {
lid: 1,
species_id: 2,
genus_name: 'Genus',
species_name: 'species',
id_status: 'ok',
orig_entry: 'orig',
source_name: 'src',
nis: 10,
pct: 50,
quad: 1,
mni: 2,
qua: 'a',
body_mass: 100,
},
} as never)
})
it('uses Quantity label with mapped values and places source fields in Identification', () => {
render(<OccurrenceCoreTab />)
const [identification, counts, size] = capturedArrayFrameProps
expect(identification?.title).toBe('Identification')
expect(identification?.array.some(row => row[0] === 'Original entry')).toBe(true)
expect(identification?.array.some(row => row[0] === 'Source name')).toBe(true)
expect(counts?.title).toBe('Occurrence counts')
expect(counts?.array.some(row => row[0] === 'Quantity' && row[1] === 'Abundant')).toBe(true)
expect(size?.title).toBe('Size')
expect(size?.array).toEqual([['Body mass', '100']])
})
})
@@ -0,0 +1,92 @@
import { describe, expect, it, jest, beforeEach } from '@jest/globals'
import { render, screen } from '@testing-library/react'
import { MemoryRouter, Route, Routes } from 'react-router-dom'
import { OccurrenceDetails } from '../OccurrenceDetails'
import { useOccurrenceDetails } from '@/hooks/useOccurrenceDetails'
jest.mock('@/hooks/useOccurrenceDetails', () => ({
useOccurrenceDetails: jest.fn(),
}))
jest.mock('../Tabs/OccurrenceCoreTab', () => ({ OccurrenceCoreTab: () => <div>Core tab</div> }))
jest.mock('../Tabs/OccurrenceWearTab', () => ({ OccurrenceWearTab: () => <div>Wear tab</div> }))
jest.mock('../Tabs/OccurrenceIsotopeTab', () => ({ OccurrenceIsotopeTab: () => <div>Isotope tab</div> }))
jest.mock('@/components/DetailView/common/UpdateTab', () => ({ UpdateTab: () => <div>Updates placeholder</div> }))
jest.mock('@/components/DetailView/DetailView', () => ({
DetailView: ({ tabs }: { tabs: Array<{ title: string }> }) => (
<div data-testid="occurrence-detail-view">{tabs.map(tab => tab.title).join('|')}</div>
),
}))
const mockUseOccurrenceDetails = useOccurrenceDetails as jest.MockedFunction<typeof useOccurrenceDetails>
describe('OccurrenceDetails', () => {
beforeEach(() => {
mockUseOccurrenceDetails.mockReset()
})
it('renders occurrence tabs including Updates placeholder tab', () => {
mockUseOccurrenceDetails.mockReturnValue({
occurrence: {
lid: 1,
species_id: 2,
loc_status: false,
loc_name: 'Loc',
country: 'Country',
genus_name: 'Genus',
species_name: 'species',
unique_identifier: null,
dms_lat: null,
dms_long: null,
bfa_max: null,
bfa_min: null,
max_age: null,
min_age: null,
nis: null,
pct: null,
quad: null,
mni: null,
qua: null,
id_status: null,
orig_entry: null,
source_name: null,
body_mass: null,
mesowear: null,
mw_or_high: null,
mw_or_low: null,
mw_cs_sharp: null,
mw_cs_round: null,
mw_cs_blunt: null,
mw_scale_min: null,
mw_scale_max: null,
mw_value: null,
microwear: null,
dc13_mean: null,
dc13_n: null,
dc13_max: null,
dc13_min: null,
dc13_stdev: null,
do18_mean: null,
do18_n: null,
do18_max: null,
do18_min: null,
do18_stdev: null,
now_oau: [],
},
isLoading: false,
isError: false,
refetch: jest.fn(() => Promise.resolve(undefined)),
})
render(
<MemoryRouter initialEntries={['/occurrence/1/2?tab=3']}>
<Routes>
<Route path="/occurrence/:lid/:speciesId" element={<OccurrenceDetails />} />
</Routes>
</MemoryRouter>
)
expect(screen.getByTestId('occurrence-detail-view').textContent).toBe('Core|Wear|Isotopes|Updates')
})
})
+7 -3
View File
@@ -108,9 +108,13 @@ export const Page = <T extends Record<string, unknown>>({
allowedRoles?: Role[]
getEditRights: (user: UserState, id: string | number) => EditRights
}) => {
const { id } = useParams()
const params = useParams()
const { id, lid } = params
const speciesId = params.speciesId
const hasDetailParams = Boolean(id || (lid && speciesId))
const editId = id ?? lid ?? ''
const user = useUser()
const editRights = ENABLE_WRITE && user ? getEditRights(user, id!) : {}
const editRights = ENABLE_WRITE && user ? getEditRights(user, editId) : {}
if ((id === 'new' && !editRights.new) || (allowedRoles && !allowedRoles.includes(user.role)))
return <Box>Your user is not authorized to view this page.</Box>
return (
@@ -121,7 +125,7 @@ export const Page = <T extends Record<string, unknown>>({
createTitle={createTitle}
createSubtitle={createSubtitle ? createSubtitle : () => ''}
>
{id ? detailView : tableView}
{hasDetailParams ? detailView : tableView}
</PageContextProvider>
)
}
@@ -220,6 +220,9 @@ export const LocalitySpeciesTab = () => {
field="now_ls"
visible_data={sortedLocalitySpeciesRows}
enableAdvancedTableControls={true}
idFieldName="lid"
url="occurrence"
getDetailPath={row => `/occurrence/${row.lid}/${row.species_id}`}
/>
</Grouped>
)
@@ -22,6 +22,9 @@ type EditableTableProps = {
columns: MRT_ColumnDef<SpeciesLocality>[]
field: string
enableAdvancedTableControls?: boolean
idFieldName?: string
url?: string
getDetailPath?: (row: SpeciesLocality) => string
}
const editableTableMock = jest.fn<(props: EditableTableProps) => JSX.Element>()
@@ -74,6 +77,15 @@ describe('LocalitySpeciesTab MW Score rendering', () => {
expect(editableTableProps?.enableAdvancedTableControls).toBe(true)
})
it('builds occurrence detail paths using lid and species_id', () => {
const editableTableProps = editableTableMock.mock.calls[0]?.[0]
const path = editableTableProps?.getDetailPath?.({ lid: 20920, species_id: 21052 } as SpeciesLocality)
expect(editableTableProps?.idFieldName).toBe('lid')
expect(editableTableProps?.url).toBe('occurrence')
expect(path).toBe('/occurrence/20920/21052')
})
it('uses occurrence terminology in modal button and group heading', () => {
const grouped = document.querySelector('[data-testid="grouped"]')
const editingModal = document.querySelector('[data-testid="editing-modal"]')
@@ -14,6 +14,7 @@ export const ActionComponent = <T extends MRT_RowData>({
url,
selectorFn,
tableRowAction,
getDetailPath,
}: {
row: MRT_Row<T>
idFieldName: keyof T
@@ -21,6 +22,7 @@ export const ActionComponent = <T extends MRT_RowData>({
url: string | undefined
selectorFn?: (id: T) => void
tableRowAction?: (row: T) => void
getDetailPath?: (row: T) => string
}) => {
const navigate = useNavigate()
const { previousTableUrls, setPreviousTableUrls } = usePageContext()
@@ -82,7 +84,8 @@ export const ActionComponent = <T extends MRT_RowData>({
}
setPreviousTableUrls([...previousTableUrls, `${location.pathname}?tab=${searchParams.get('tab')}`])
navigate(`/${url}/${id}`)
const detailPath = getDetailPath ? getDetailPath(row.original) : `/${url}/${id}`
navigate(detailPath, { state: { returnTo: `${location.pathname}${location.search}` } })
}
return (
@@ -12,7 +12,7 @@ import {
MRT_Row,
type MRT_FilterFn,
} from 'material-react-table'
import { Alert, Box, CircularProgress, Paper, Tooltip } from '@mui/material'
import { Alert, Box, CircularProgress, IconButton, Paper, Tooltip } from '@mui/material'
import type { FetchBaseQueryError } from '@reduxjs/toolkit/query'
import type { SerializedError } from '@reduxjs/toolkit'
import type { FilterFn } from '@tanstack/table-core'
@@ -25,6 +25,8 @@ import { defaultPagination, defaultPaginationSmall } from '@/common'
import '../../styles/TableView.css'
import { TableToolBar } from './TableToolBar'
import NotListedLocationIcon from '@mui/icons-material/NotListedLocation'
import ManageSearchIcon from '@mui/icons-material/ManageSearch'
import PolicyIcon from '@mui/icons-material/Policy'
import '../../styles/tableview/TableView.css'
import { resolveErrorMessage, resolveErrorStatus } from './errorUtils'
@@ -57,6 +59,7 @@ export const TableView = <T extends MRT_RowData>({
checkRowRestriction,
selectorFn,
tableRowAction,
getDetailPath,
url,
title,
kmlExport,
@@ -79,6 +82,7 @@ export const TableView = <T extends MRT_RowData>({
checkRowRestriction?: (row: T) => boolean
selectorFn?: (id: T) => void
tableRowAction?: (row: T) => void
getDetailPath?: (row: T) => string
url?: string
title: string
kmlExport?: (table: MRT_TableInstance<T>) => void
@@ -211,6 +215,11 @@ export const TableView = <T extends MRT_RowData>({
else rowCount = data.length
}
const resolveDetailPath = (row: T) => {
if (getDetailPath) return getDetailPath(row)
return `/${url}/${row[idFieldName] as string | number}`
}
const muiTableBodyRowProps = ({ row }: { row: MRT_Row<T> }) => ({
onClick: () => {
const sanitizedFilters = sanitizeColumnFilters(columnFilters)
@@ -221,7 +230,9 @@ export const TableView = <T extends MRT_RowData>({
...previousTableUrls,
`${location.pathname}?&${columnFilterToUrl}&${sortingToUrl}&${paginationToUrl}`,
])
navigate(`/${url}/${row.original[idFieldName]}`)
navigate(resolveDetailPath(row.original), {
state: { returnTo: `${location.pathname}${location.search}` },
})
},
sx: {
cursor: 'pointer',
@@ -272,9 +283,45 @@ export const TableView = <T extends MRT_RowData>({
const showSynonymIndicator = Boolean(row.original.has_synonym)
const showNoLocalityIndicator = Boolean(row.original.has_no_locality)
const hasCustomDetailPath = Boolean(getDetailPath && !selectorFn && !tableRowAction)
return (
<Box className="row-actions-column">
<ActionComponent {...{ selectorFn, url, checkRowRestriction, row, idFieldName }} />
{hasCustomDetailPath ? (
<Box display="flex" alignItems="center" gap={0.5}>
<Tooltip placement="top" title="View details">
<IconButton
aria-label="View details"
data-cy={`details-button-${String(row.original[idFieldName])}`}
onClick={event => {
event.stopPropagation()
const sanitizedFilters = sanitizeColumnFilters(columnFilters)
const columnFilterToUrl = `columnfilters=${JSON.stringify(sanitizedFilters)}`
const sortingToUrl = `sorting=${JSON.stringify(sorting)}`
const paginationToUrl = `pagination=${JSON.stringify(pagination)}`
setPreviousTableUrls([
...previousTableUrls,
`${location.pathname}?&${columnFilterToUrl}&${sortingToUrl}&${paginationToUrl}`,
])
navigate(resolveDetailPath(row.original), {
state: { returnTo: `${location.pathname}${location.search}` },
})
}}
size="small"
sx={{ p: 0.5 }}
>
<ManageSearchIcon fontSize="small" />
</IconButton>
</Tooltip>
{checkRowRestriction && checkRowRestriction(row.original) && (
<Tooltip placement="top" title="This item has restricted visibility">
<PolicyIcon aria-label="Restricted visibility indicator" color="primary" fontSize="medium" />
</Tooltip>
)}
</Box>
) : (
<ActionComponent {...{ selectorFn, url, checkRowRestriction, row, idFieldName }} />
)}
{showSynonymIndicator && (
<ActionComponent
{...{
+17 -4
View File
@@ -4,6 +4,7 @@ import {
LocalityDetailsType,
TimeBoundDetailsType,
PersonDetailsType,
OccurrenceDetailsType,
ProjectDetailsType,
RegionDetails as RegionDetailsType,
Role,
@@ -33,6 +34,7 @@ import { MuseumTable } from './Museum/MuseumTable'
import { MuseumDetails } from './Museum/MuseumDetails'
import { createReferenceSubtitle, createReferenceTitle } from './Reference/referenceFormatting'
import { UnsavedChangesProvider } from './UnsavedChangesProvider'
import { OccurrenceDetails } from './Occurrence/OccurrenceDetails'
const noRights: EditRights = {}
const fullRights: EditRights = { new: true, edit: true, delete: true }
@@ -63,12 +65,23 @@ export const frontPage = <FrontPage />
export const crossSearchPage = (
<Page
tableView={<CrossSearchTable />}
detailView={<LocalityDetails />}
detailView={<OccurrenceDetails />}
viewName="occurrence"
idFieldName="lid"
createTitle={(loc: LocalityDetailsType) => `${loc.lid} ${loc.loc_name}, ${loc.country}`}
createSubtitle={(loc: LocalityDetailsType) =>
`${loc.dms_lat}, ${loc.dms_long}` + `\n${loc.max_age} Ma (${loc.bfa_max}) – ${loc.min_age} Ma (${loc.bfa_min})`
createTitle={(occurrence: OccurrenceDetailsType) => {
const genusSpecies = `${occurrence.genus_name} ${occurrence.species_name}`.trim()
const hasUsefulUniqueIdentifier =
!!occurrence.unique_identifier &&
occurrence.unique_identifier.trim() !== '-' &&
occurrence.unique_identifier.trim() !== ''
const speciesDisplay = genusSpecies || (hasUsefulUniqueIdentifier ? occurrence.unique_identifier : '-')
return `${occurrence.lid} ${occurrence.loc_name}, ${occurrence.country}
${speciesDisplay} (species_id: ${occurrence.species_id})`
}}
createSubtitle={(occurrence: OccurrenceDetailsType) =>
`${occurrence.dms_lat ?? ''}, ${occurrence.dms_long ?? ''}
${occurrence.max_age ?? ''} Ma (${occurrence.bfa_max ?? ''}) – ${occurrence.min_age ?? ''} Ma (${occurrence.bfa_min ?? ''})`
}
getEditRights={(user: UserState, id: string | number) => {
if ([Role.Admin, Role.EditUnrestricted].includes(user.role)) return fullRights
@@ -0,0 +1,51 @@
import { beforeEach, describe, expect, it, jest } from '@jest/globals'
import { renderHook } from '@testing-library/react'
import { useOccurrenceDetails } from './useOccurrenceDetails'
import { useGetOccurrenceDetailsQuery } from '@/redux/api'
jest.mock('@/redux/api', () => ({
useGetOccurrenceDetailsQuery: jest.fn(),
}))
const mockUseGetOccurrenceDetailsQuery = useGetOccurrenceDetailsQuery as jest.MockedFunction<
typeof useGetOccurrenceDetailsQuery
>
describe('useOccurrenceDetails', () => {
beforeEach(() => {
mockUseGetOccurrenceDetailsQuery.mockReset()
})
it('passes lid and speciesId to query hook and returns mapped data', () => {
const refetch = jest.fn(() => Promise.resolve(undefined))
mockUseGetOccurrenceDetailsQuery.mockReturnValue({
data: { lid: 10, species_id: 20, loc_name: 'Loc' },
isLoading: false,
isFetching: false,
isError: false,
refetch,
} as unknown as ReturnType<typeof useGetOccurrenceDetailsQuery>)
const { result } = renderHook(() => useOccurrenceDetails(10, 20))
expect(mockUseGetOccurrenceDetailsQuery).toHaveBeenCalledWith({ lid: 10, speciesId: 20 })
expect(result.current.occurrence?.lid).toBe(10)
expect(result.current.occurrence?.species_id).toBe(20)
expect(result.current.isLoading).toBe(false)
expect(result.current.isError).toBe(false)
})
it('reports loading when query is loading or fetching', () => {
mockUseGetOccurrenceDetailsQuery.mockReturnValue({
data: undefined,
isLoading: false,
isFetching: true,
isError: false,
refetch: jest.fn(() => Promise.resolve(undefined)),
} as unknown as ReturnType<typeof useGetOccurrenceDetailsQuery>)
const { result } = renderHook(() => useOccurrenceDetails(10, 20))
expect(result.current.isLoading).toBe(true)
})
})
@@ -0,0 +1,22 @@
import { skipToken } from '@reduxjs/toolkit/query'
import { useGetOccurrenceDetailsQuery } from '@/redux/api'
import { OccurrenceDetailsType } from '@/shared/types'
type UseOccurrenceDetailsResult = {
occurrence: OccurrenceDetailsType | undefined
isLoading: boolean
isError: boolean
refetch: () => Promise<unknown>
}
export const useOccurrenceDetails = (lid: number | null, speciesId: number | null): UseOccurrenceDetailsResult => {
const queryArg = lid !== null && speciesId !== null ? { lid, speciesId } : skipToken
const occurrenceQuery = useGetOccurrenceDetailsQuery(queryArg)
return {
occurrence: occurrenceQuery.data,
isLoading: occurrenceQuery.isLoading || occurrenceQuery.isFetching,
isError: occurrenceQuery.isError,
refetch: occurrenceQuery.refetch,
}
}
@@ -68,6 +68,17 @@ describe('useReturnNavigation', () => {
expect(result.current.fallbackTarget).toBe('/reference')
})
it('falls back to /occurrence from a composite occurrence detail path when viewName is unavailable', () => {
mockPageContext.viewName = ''
const wrapper = ({ children }: { children: ReactNode }) => (
<MemoryRouter initialEntries={[{ pathname: '/occurrence/20920/21052' }]}>{children}</MemoryRouter>
)
const { result } = renderHook(() => useReturnNavigation(), { wrapper })
expect(result.current.fallbackTarget).toBe('/occurrence')
})
it('prefers an explicit fallback option', () => {
const wrapper = ({ children }: { children: ReactNode }) => (
<MemoryRouter initialEntries={['/reference/123']}>{children}</MemoryRouter>
@@ -54,6 +54,11 @@ export const useReturnNavigation = ({ fallback }: UseReturnNavigationOptions = {
}
const path = location.pathname
if (/^\/occurrence\/[^/]+\/[^/]+$/.test(path)) {
return '/occurrence'
}
const secondSlashIndex = path.indexOf('/', 1)
if (secondSlashIndex === -1) {
return path || '/'
+15
View File
@@ -10,6 +10,7 @@ import { BACKEND_URL } from '../util/config'
import { RootState } from './store'
import { clearUser, setToken } from './userReducer'
import { QueryReturnValue } from 'node_modules/@reduxjs/toolkit/dist/query/baseQueryTypes'
import { OccurrenceDetailsType } from '@/shared/types'
const baseQuery = fetchBaseQuery({
baseUrl: BACKEND_URL,
@@ -74,7 +75,21 @@ export const api = createApi({
'project',
'projects',
'geoname',
'occurrence',
],
baseQuery: baseQueryWithReauth,
endpoints: () => ({}),
})
const occurrenceApi = api.injectEndpoints({
endpoints: builder => ({
getOccurrenceDetails: builder.query<OccurrenceDetailsType, { lid: number; speciesId: number }>({
query: ({ lid, speciesId }) => ({
url: `/occurrence/${lid}/${speciesId}`,
}),
providesTags: result => (result ? [{ type: 'occurrence', id: `${result.lid}-${result.species_id}` }] : []),
}),
}),
})
export const { useGetOccurrenceDetailsQuery } = occurrenceApi
+4 -2
View File
@@ -1,4 +1,4 @@
import { createBrowserRouter } from 'react-router-dom'
import { Navigate, createBrowserRouter } from 'react-router-dom'
import App from '../App'
import { Login } from '../components/Login'
import { EmailPage } from '../components/EmailPage'
@@ -24,7 +24,9 @@ const router = createBrowserRouter([
element: <App />,
children: [
{ index: true, element: frontPage },
{ path: 'occurrence/:id?', element: crossSearchPage },
{ path: 'occurrence/:lid/:speciesId', element: crossSearchPage },
{ path: 'occurrence/:id', element: <Navigate to="/occurrence" replace /> },
{ path: 'occurrence', element: crossSearchPage },
{ path: 'crosssearch/:id?', element: crossSearchPage },
{ path: 'locality/:id?', element: localityPage },
{ path: 'species/:id?', element: speciesPage },
+56
View File
@@ -29,6 +29,53 @@ export type SpeciesLocality = FixBigInt<Prisma.now_ls> & {
mw_scale_max: number | null
mw_value: number | null
}
export type OccurrenceDetailsType = {
lid: number
species_id: number
loc_status: boolean | null
loc_name: string
country: string
genus_name: string
species_name: string
unique_identifier: string | null
dms_lat: string | null
dms_long: string | null
bfa_max: string | null
bfa_min: string | null
max_age: number | null
min_age: number | null
nis: number | null
pct: number | null
quad: number | null
mni: number | null
qua: string | null
id_status: string | null
orig_entry: string | null
source_name: string | null
body_mass: number | null
mesowear: string | null
mw_or_high: number | null
mw_or_low: number | null
mw_cs_sharp: number | null
mw_cs_round: number | null
mw_cs_blunt: number | null
mw_scale_min: number | null
mw_scale_max: number | null
mw_value: number | null
microwear: string | null
dc13_mean: number | null
dc13_n: number | null
dc13_max: number | null
dc13_min: number | null
dc13_stdev: number | null
do18_mean: number | null
do18_n: number | null
do18_max: number | null
do18_min: number | null
do18_stdev: number | null
now_oau: OccurrenceUpdate[]
}
export type LocalityUpdate = Prisma.now_lau & { now_lr: LocalityReference[] } & { updates: UpdateLog[] }
export type SpeciesUpdate = Prisma.now_sau & { now_sr: SpeciesReference[] } & { updates: UpdateLog[] }
export type Museum = Omit<Prisma.com_mlist, 'used_morph' | 'used_now' | 'used_gene'>
@@ -479,6 +526,15 @@ export type TimeBoundReference = Prisma.now_br & { ref_ref: ReferenceOfUpdate }
export type AnyReference = LocalityReference | SpeciesReference | TimeUnitReference | TimeBoundReference
export type OccurrenceUpdate = {
occ_date: Date | null
occ_authorizer: string
occ_coordinator: string
occ_comment: string
references: AnyReference[]
updates: UpdateLog[]
}
export type ReferenceJournalType = {
journal_id?: number
journal_title?: string
@@ -23,6 +23,7 @@ type MockedColumn = {
type MockedTableViewProps = {
data: CrossSearchRow[] | undefined
columns?: MockedColumn[]
getDetailPath?: (row: CrossSearchRow) => string
}
const mockTableView = jest.fn((props: MockedTableViewProps) => {
@@ -127,6 +128,15 @@ describe('CrossSearchTable column configuration', () => {
expect(accessorFn?.(sample)).toBe('S1234')
})
it('builds composite-key occurrence detail paths for row navigation', () => {
expect(typeof capturedProps?.getDetailPath).toBe('function')
const path = capturedProps?.getDetailPath?.(
createCrossSearch({ lid_now_loc: 20920, species_id_com_species: 21052 })
)
expect(path).toBe('/occurrence/20920/21052')
})
it('formats coordinates with a maximum of three decimals for consistency with Locality table', () => {
const decLatColumn = (capturedProps?.columns ?? []).find(column => column?.accessorKey === 'dec_lat')
const decLongColumn = (capturedProps?.columns ?? []).find(column => column?.accessorKey === 'dec_long')