mirror of
https://github.com/nowcommunity/nowdatabase.git
synced 2026-10-12 19:13:52 +03:00
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:
@@ -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' })
|
||||
})
|
||||
})
|
||||
@@ -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))
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import { Router } from 'express'
|
||||
import { getOccurrenceDetail } from '../controllers/occurrenceController'
|
||||
|
||||
const router = Router()
|
||||
|
||||
router.get('/:lid/:speciesId', getOccurrenceDetail)
|
||||
|
||||
export default router
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
{...{
|
||||
|
||||
@@ -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 || '/'
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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')
|
||||
|
||||
Reference in New Issue
Block a user