diff --git a/backend/src/api-tests/occurrence/getByCompositeKey.test.ts b/backend/src/api-tests/occurrence/getByCompositeKey.test.ts new file mode 100644 index 00000000..27174f5c --- /dev/null +++ b/backend/src/api-tests/occurrence/getByCompositeKey.test.ts @@ -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>('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>('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>('occurrence/not-a-number/21052', 'GET') + expect(response.status).toBe(400) + expect(response.body).toEqual({ message: 'lid and species_id must be valid integers' }) + }) +}) diff --git a/backend/src/app.ts b/backend/src/app.ts index dec01bc4..e5acb263 100644 --- a/backend/src/app.ts +++ b/backend/src/app.ts @@ -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) diff --git a/backend/src/controllers/occurrenceController.ts b/backend/src/controllers/occurrenceController.ts new file mode 100644 index 00000000..7a13d204 --- /dev/null +++ b/backend/src/controllers/occurrenceController.ts @@ -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)) +} diff --git a/backend/src/routes/occurrence.ts b/backend/src/routes/occurrence.ts new file mode 100644 index 00000000..0568994b --- /dev/null +++ b/backend/src/routes/occurrence.ts @@ -0,0 +1,8 @@ +import { Router } from 'express' +import { getOccurrenceDetail } from '../controllers/occurrenceController' + +const router = Router() + +router.get('/:lid/:speciesId', getOccurrenceDetail) + +export default router diff --git a/backend/src/services/occurrenceService.ts b/backend/src/services/occurrenceService.ts new file mode 100644 index 00000000..bbce3831 --- /dev/null +++ b/backend/src/services/occurrenceService.ts @@ -0,0 +1,90 @@ +import { Role, User } from '../../../frontend/src/shared/types' +import { AccessError } from '../middlewares/authorizer' +import { nowDb } from '../utils/db' +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 } +} + +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 + 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 + }> + >(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() + } + } + + return occurrence +} diff --git a/backend/src/services/queries/crossSearchQuery.ts b/backend/src/services/queries/crossSearchQuery.ts index 2229b018..a4d81895 100644 --- a/backend/src/services/queries/crossSearchQuery.ts +++ b/backend/src/services/queries/crossSearchQuery.ts @@ -28,6 +28,52 @@ const generateWhereClause = (showAll: boolean, allowedLocalities: Array, 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, + com_species.genus_name, + com_species.species_name + 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, diff --git a/backend/src/unit-tests/occurrence/occurrenceService.test.ts b/backend/src/unit-tests/occurrence/occurrenceService.test.ts new file mode 100644 index 00000000..84a43fe3 --- /dev/null +++ b/backend/src/unit-tests/occurrence/occurrenceService.test.ts @@ -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) + }) +}) diff --git a/docs/occurrence-detail-page-plan.md b/docs/occurrence-detail-page-plan.md new file mode 100644 index 00000000..35ee72a6 --- /dev/null +++ b/docs/occurrence-detail-page-plan.md @@ -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// opens Occurrence detail page", + "Navigating to /occurrence?columnfilters=[]&sorting=[]&pagination={...} still opens occurrence list", + "Legacy /occurrence/ 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=" + ], + "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//", + "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. diff --git a/frontend/src/components/CrossSearch/CrossSearchTable.tsx b/frontend/src/components/CrossSearch/CrossSearchTable.tsx index 2326d735..ec498cfd 100755 --- a/frontend/src/components/CrossSearch/CrossSearchTable.tsx +++ b/frontend/src/components/CrossSearch/CrossSearchTable.tsx @@ -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} diff --git a/frontend/src/components/DetailView/common/UpdateTab.test.tsx b/frontend/src/components/DetailView/common/UpdateTab.test.tsx new file mode 100644 index 00000000..59eb0e95 --- /dev/null +++ b/frontend/src/components/DetailView/common/UpdateTab.test.tsx @@ -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 }) =>
{children}
, +})) + +jest.mock('./SimpleTable', () => ({ + SimpleTable: () =>
, +})) + +jest.mock('./ReferenceList', () => ({ + ReferenceList: () =>
, +})) + +describe('UpdateTab', () => { + it('renders placeholder message when placeholder mode is enabled', () => { + render() + + expect(screen.getByText('Updates tab placeholder. Implementation is pending.')).toBeTruthy() + }) +}) diff --git a/frontend/src/components/DetailView/common/UpdateTab.tsx b/frontend/src/components/DetailView/common/UpdateTab.tsx index f3537709..cea6eaa2 100755 --- a/frontend/src/components/DetailView/common/UpdateTab.tsx +++ b/frontend/src/components/DetailView/common/UpdateTab.tsx @@ -16,13 +16,25 @@ export const UpdateTab = { const { data } = useDetailContext() + if (placeholderMessage) { + return ( + + {placeholderMessage} + + ) + } + + if (!refFieldName || !updatesFieldName) return null + const columns: MRT_ColumnDef[] = [ { accessorKey: `${prefix}_date`, diff --git a/frontend/src/components/Occurrence/OccurrenceDetails.tsx b/frontend/src/components/Occurrence/OccurrenceDetails.tsx new file mode 100644 index 00000000..844153a8 --- /dev/null +++ b/frontend/src/components/Occurrence/OccurrenceDetails.tsx @@ -0,0 +1,81 @@ +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: '', + 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, +} + +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
Error loading occurrence data
+ if (isLoading || !occurrence) return + + document.title = `Occurrence - ${occurrence.lid}/${occurrence.species_id}` + + const tabs: TabType[] = [ + { title: 'Core', content: }, + { title: 'Wear', content: }, + { title: 'Isotopes', content: }, + { + title: 'Updates', + content: , + }, + ] + + return ( + + tabs={tabs} + data={occurrence ?? emptyOccurrence} + validator={validateOccurrence} + /> + ) +} diff --git a/frontend/src/components/Occurrence/Tabs/OccurrenceCoreTab.tsx b/frontend/src/components/Occurrence/Tabs/OccurrenceCoreTab.tsx new file mode 100644 index 00000000..a14b2b2e --- /dev/null +++ b/frontend/src/components/Occurrence/Tabs/OccurrenceCoreTab.tsx @@ -0,0 +1,40 @@ +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 OccurrenceCoreTab = () => { + const { data } = useDetailContext() + + return ( + + {[ + , + , + ]} + + ) +} diff --git a/frontend/src/components/Occurrence/Tabs/OccurrenceIsotopeTab.tsx b/frontend/src/components/Occurrence/Tabs/OccurrenceIsotopeTab.tsx new file mode 100644 index 00000000..81731c30 --- /dev/null +++ b/frontend/src/components/Occurrence/Tabs/OccurrenceIsotopeTab.tsx @@ -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() + + return ( + + {[ + , + , + ]} + + ) +} diff --git a/frontend/src/components/Occurrence/Tabs/OccurrenceWearTab.tsx b/frontend/src/components/Occurrence/Tabs/OccurrenceWearTab.tsx new file mode 100644 index 00000000..d3e0fdac --- /dev/null +++ b/frontend/src/components/Occurrence/Tabs/OccurrenceWearTab.tsx @@ -0,0 +1,39 @@ +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() + + return ( + + {[ + , + , + ]} + + ) +} diff --git a/frontend/src/components/Occurrence/__tests__/OccurrenceDetails.test.tsx b/frontend/src/components/Occurrence/__tests__/OccurrenceDetails.test.tsx new file mode 100644 index 00000000..97b5d6b2 --- /dev/null +++ b/frontend/src/components/Occurrence/__tests__/OccurrenceDetails.test.tsx @@ -0,0 +1,84 @@ +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: () =>
Core tab
})) +jest.mock('../Tabs/OccurrenceWearTab', () => ({ OccurrenceWearTab: () =>
Wear tab
})) +jest.mock('../Tabs/OccurrenceIsotopeTab', () => ({ OccurrenceIsotopeTab: () =>
Isotope tab
})) +jest.mock('@/components/DetailView/common/UpdateTab', () => ({ UpdateTab: () =>
Updates placeholder
})) + +jest.mock('@/components/DetailView/DetailView', () => ({ + DetailView: ({ tabs }: { tabs: Array<{ title: string }> }) => ( +
{tabs.map(tab => tab.title).join('|')}
+ ), +})) + +const mockUseOccurrenceDetails = useOccurrenceDetails as jest.MockedFunction + +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', + 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, + }, + isLoading: false, + isError: false, + refetch: jest.fn(() => Promise.resolve(undefined)), + }) + + render( + + + } /> + + + ) + + expect(screen.getByTestId('occurrence-detail-view').textContent).toBe('Core|Wear|Isotopes|Updates') + }) +}) diff --git a/frontend/src/components/Page.tsx b/frontend/src/components/Page.tsx index 72059927..31c76c74 100755 --- a/frontend/src/components/Page.tsx +++ b/frontend/src/components/Page.tsx @@ -108,9 +108,13 @@ export const Page = >({ 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 Your user is not authorized to view this page. return ( @@ -121,7 +125,7 @@ export const Page = >({ createTitle={createTitle} createSubtitle={createSubtitle ? createSubtitle : () => ''} > - {id ? detailView : tableView} + {hasDetailParams ? detailView : tableView} ) } diff --git a/frontend/src/components/TableView/TableView.tsx b/frontend/src/components/TableView/TableView.tsx index 8300e663..dd31ff3b 100755 --- a/frontend/src/components/TableView/TableView.tsx +++ b/frontend/src/components/TableView/TableView.tsx @@ -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 = ({ checkRowRestriction, selectorFn, tableRowAction, + getDetailPath, url, title, kmlExport, @@ -79,6 +82,7 @@ export const TableView = ({ checkRowRestriction?: (row: T) => boolean selectorFn?: (id: T) => void tableRowAction?: (row: T) => void + getDetailPath?: (row: T) => string url?: string title: string kmlExport?: (table: MRT_TableInstance) => void @@ -211,6 +215,11 @@ export const TableView = ({ 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 }) => ({ onClick: () => { const sanitizedFilters = sanitizeColumnFilters(columnFilters) @@ -221,7 +230,7 @@ export const TableView = ({ ...previousTableUrls, `${location.pathname}?&${columnFilterToUrl}&${sortingToUrl}&${paginationToUrl}`, ]) - navigate(`/${url}/${row.original[idFieldName]}`) + navigate(resolveDetailPath(row.original)) }, sx: { cursor: 'pointer', @@ -272,9 +281,43 @@ export const TableView = ({ const showSynonymIndicator = Boolean(row.original.has_synonym) const showNoLocalityIndicator = Boolean(row.original.has_no_locality) + const hasCustomDetailPath = Boolean(getDetailPath && !selectorFn && !tableRowAction) + return ( - + {hasCustomDetailPath ? ( + + + { + 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)) + }} + size="small" + sx={{ p: 0.5 }} + > + + + + {checkRowRestriction && checkRowRestriction(row.original) && ( + + + + )} + + ) : ( + + )} {showSynonymIndicator && ( ({ + 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) + + 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) + + const { result } = renderHook(() => useOccurrenceDetails(10, 20)) + + expect(result.current.isLoading).toBe(true) + }) +}) diff --git a/frontend/src/hooks/useOccurrenceDetails.ts b/frontend/src/hooks/useOccurrenceDetails.ts new file mode 100644 index 00000000..98f28e29 --- /dev/null +++ b/frontend/src/hooks/useOccurrenceDetails.ts @@ -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 +} + +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, + } +} diff --git a/frontend/src/hooks/useReturnNavigation.test.tsx b/frontend/src/hooks/useReturnNavigation.test.tsx index b815ba24..8dfad18c 100644 --- a/frontend/src/hooks/useReturnNavigation.test.tsx +++ b/frontend/src/hooks/useReturnNavigation.test.tsx @@ -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 }) => ( + {children} + ) + + const { result } = renderHook(() => useReturnNavigation(), { wrapper }) + + expect(result.current.fallbackTarget).toBe('/occurrence') + }) it('prefers an explicit fallback option', () => { const wrapper = ({ children }: { children: ReactNode }) => ( {children} diff --git a/frontend/src/hooks/useReturnNavigation.ts b/frontend/src/hooks/useReturnNavigation.ts index 0923695f..2da0495d 100644 --- a/frontend/src/hooks/useReturnNavigation.ts +++ b/frontend/src/hooks/useReturnNavigation.ts @@ -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 || '/' diff --git a/frontend/src/redux/api.ts b/frontend/src/redux/api.ts index 916e2244..b39032d9 100755 --- a/frontend/src/redux/api.ts +++ b/frontend/src/redux/api.ts @@ -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({ + query: ({ lid, speciesId }) => ({ + url: `/occurrence/${lid}/${speciesId}`, + }), + providesTags: result => (result ? [{ type: 'occurrence', id: `${result.lid}-${result.species_id}` }] : []), + }), + }), +}) + +export const { useGetOccurrenceDetailsQuery } = occurrenceApi diff --git a/frontend/src/router/index.tsx b/frontend/src/router/index.tsx index 55d756a4..a26a6b9a 100644 --- a/frontend/src/router/index.tsx +++ b/frontend/src/router/index.tsx @@ -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: , children: [ { index: true, element: frontPage }, - { path: 'occurrence/:id?', element: crossSearchPage }, + { path: 'occurrence/:lid/:speciesId', element: crossSearchPage }, + { path: 'occurrence/:id', element: }, + { path: 'occurrence', element: crossSearchPage }, { path: 'crosssearch/:id?', element: crossSearchPage }, { path: 'locality/:id?', element: localityPage }, { path: 'species/:id?', element: speciesPage }, diff --git a/frontend/src/shared/types/data.ts b/frontend/src/shared/types/data.ts index a05a4c87..911df2f9 100755 --- a/frontend/src/shared/types/data.ts +++ b/frontend/src/shared/types/data.ts @@ -29,6 +29,45 @@ export type SpeciesLocality = FixBigInt & { 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 + 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 +} + 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 diff --git a/frontend/tests/components/CrossSearchTable.test.tsx b/frontend/tests/components/CrossSearchTable.test.tsx index d33c5b30..887ca7af 100644 --- a/frontend/tests/components/CrossSearchTable.test.tsx +++ b/frontend/tests/components/CrossSearchTable.test.tsx @@ -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')