mirror of
https://github.com/nowcommunity/nowdatabase.git
synced 2026-10-12 19:13:52 +03:00
Improve species merge UX and docs
This commit is contained in:
@@ -128,3 +128,10 @@ When tradeoffs appear, prefer:
|
|||||||
- Be concise and practical.
|
- Be concise and practical.
|
||||||
- Mention files changed, validation performed, and any unresolved risk.
|
- Mention files changed, validation performed, and any unresolved risk.
|
||||||
- If a task was only partially verifiable locally, say so clearly.
|
- If a task was only partially verifiable locally, say so clearly.
|
||||||
|
|
||||||
|
## Database Safety (Mandatory)
|
||||||
|
|
||||||
|
- Never run database reset or restore commands against the **development** database unless the user explicitly asks for it.
|
||||||
|
- Treat `/test/reset-test-database` and any SQL in `test_data/sqlfiles/` as **test-only** fixtures. Do not point dev services at those databases.
|
||||||
|
- Before running any DB reset/restore, confirm which **DB host, port, and database names** the backend is using and whether they are isolated from dev data.
|
||||||
|
- If test and dev share the same database container or port, stop and ask the user to separate them before proceeding.
|
||||||
|
|||||||
@@ -0,0 +1,325 @@
|
|||||||
|
import { afterAll, beforeAll, beforeEach, describe, expect, it } from '@jest/globals'
|
||||||
|
import { pool } from '../../utils/db'
|
||||||
|
import { login, noPermError, resetDatabaseTimeout, send, setToken } from '../utils'
|
||||||
|
import { cloneSpeciesData } from './data'
|
||||||
|
import { OCCURRENCE_MERGE_FIELDS } from '../../services/speciesMerge'
|
||||||
|
import { SpeciesDetailsType } from '../../../../frontend/src/shared/types'
|
||||||
|
|
||||||
|
type MergeResponse = {
|
||||||
|
message: string
|
||||||
|
suid: number
|
||||||
|
coordinator: string
|
||||||
|
editor: string
|
||||||
|
date: string
|
||||||
|
comment: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const buildOccurrenceChoices = (
|
||||||
|
lid: number,
|
||||||
|
overrides?: Partial<Record<(typeof OCCURRENCE_MERGE_FIELDS)[number], 'accepted' | 'obsolete'>>
|
||||||
|
) => {
|
||||||
|
const fieldChoice = OCCURRENCE_MERGE_FIELDS.reduce(
|
||||||
|
(acc, field) => {
|
||||||
|
acc[field] = 'accepted'
|
||||||
|
return acc
|
||||||
|
},
|
||||||
|
{} as Record<(typeof OCCURRENCE_MERGE_FIELDS)[number], 'accepted' | 'obsolete'>
|
||||||
|
)
|
||||||
|
|
||||||
|
if (overrides) {
|
||||||
|
for (const [field, value] of Object.entries(overrides)) {
|
||||||
|
fieldChoice[field as (typeof OCCURRENCE_MERGE_FIELDS)[number]] = value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { lid, fieldChoice }
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('Species merge endpoint', () => {
|
||||||
|
beforeAll(async () => {
|
||||||
|
const resetResult = await send('test/reset-test-database', 'GET')
|
||||||
|
expect(resetResult.status).toEqual(200)
|
||||||
|
const createUsersResult = await send('test/create-test-users', 'GET')
|
||||||
|
expect(createUsersResult.status).toEqual(200)
|
||||||
|
const loginResult = await send<{ token: string }>('user/login', 'POST', { username: 'testSu', password: 'test' })
|
||||||
|
expect(loginResult.status).toEqual(200)
|
||||||
|
setToken(loginResult.body.token)
|
||||||
|
}, resetDatabaseTimeout)
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
const loginResult = await send<{ token: string }>('user/login', 'POST', { username: 'testSu', password: 'test' })
|
||||||
|
expect(loginResult.status).toEqual(200)
|
||||||
|
setToken(loginResult.body.token)
|
||||||
|
})
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await pool.end()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('merges species, migrates synonyms, and deletes obsolete species', async () => {
|
||||||
|
const references = cloneSpeciesData().references
|
||||||
|
|
||||||
|
const acceptedPayload = {
|
||||||
|
...cloneSpeciesData(),
|
||||||
|
genus_name: 'MergeAcceptedGenus',
|
||||||
|
species_name: 'mergeAccepted',
|
||||||
|
unique_identifier: 'merge-accepted',
|
||||||
|
diet1: 'a',
|
||||||
|
now_ls: [
|
||||||
|
{
|
||||||
|
rowState: 'new',
|
||||||
|
lid: 24750,
|
||||||
|
mni: 1,
|
||||||
|
source_name: 'Accepted Source',
|
||||||
|
mesowear: 'bil',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
com_taxa_synonym: [],
|
||||||
|
comment: 'create accepted',
|
||||||
|
references,
|
||||||
|
}
|
||||||
|
|
||||||
|
const obsoletePayload = {
|
||||||
|
...cloneSpeciesData(),
|
||||||
|
genus_name: 'MergeObsoleteGenus',
|
||||||
|
species_name: 'mergeObsolete',
|
||||||
|
unique_identifier: 'merge-obsolete',
|
||||||
|
diet1: 'b',
|
||||||
|
now_ls: [
|
||||||
|
{
|
||||||
|
rowState: 'new',
|
||||||
|
lid: 24750,
|
||||||
|
mni: 2,
|
||||||
|
source_name: 'Obsolete Source',
|
||||||
|
mesowear: 'mix',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
com_taxa_synonym: [
|
||||||
|
{
|
||||||
|
rowState: 'new',
|
||||||
|
syn_genus_name: 'LegacyGenus',
|
||||||
|
syn_species_name: 'legatus',
|
||||||
|
syn_comment: 'legacy synonym',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
comment: 'create obsolete',
|
||||||
|
references,
|
||||||
|
}
|
||||||
|
|
||||||
|
const acceptedCreate = await send<{ species_id: number }>('species', 'PUT', { species: acceptedPayload })
|
||||||
|
const obsoleteCreate = await send<{ species_id: number }>('species', 'PUT', { species: obsoletePayload })
|
||||||
|
|
||||||
|
if (acceptedCreate.status !== 200) {
|
||||||
|
throw new Error(`Accepted species create failed: ${JSON.stringify(acceptedCreate.body)}`)
|
||||||
|
}
|
||||||
|
if (obsoleteCreate.status !== 200) {
|
||||||
|
throw new Error(`Obsolete species create failed: ${JSON.stringify(obsoleteCreate.body)}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const acceptedId = acceptedCreate.body.species_id
|
||||||
|
const obsoleteId = obsoleteCreate.body.species_id
|
||||||
|
|
||||||
|
const mergeResult = await send<MergeResponse>('admin/species-merge', 'POST', {
|
||||||
|
obsoleteSpeciesId: obsoleteId,
|
||||||
|
acceptedSpeciesId: acceptedId,
|
||||||
|
selectedSpeciesFieldValues: {
|
||||||
|
diet1: 'b',
|
||||||
|
},
|
||||||
|
occurrenceFieldChoices: [
|
||||||
|
buildOccurrenceChoices(24750, {
|
||||||
|
mni: 'obsolete',
|
||||||
|
source_name: 'obsolete',
|
||||||
|
mesowear: 'obsolete',
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
addObsoleteAsSynonym: true,
|
||||||
|
synonymComment: 'merged synonym',
|
||||||
|
addSourceNameToOccurrences: false,
|
||||||
|
comment: 'merge species',
|
||||||
|
references,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (mergeResult.status !== 200) {
|
||||||
|
throw new Error(`Merge failed: ${JSON.stringify(mergeResult.body)}`)
|
||||||
|
}
|
||||||
|
expect(mergeResult.body.message).toEqual('SPECIES MERGED SUCCESSFULLY')
|
||||||
|
expect(typeof mergeResult.body.suid).toEqual('number')
|
||||||
|
|
||||||
|
const { body: mergedSpecies, status: mergedStatus } = await send<SpeciesDetailsType>(`species/${acceptedId}`, 'GET')
|
||||||
|
expect(mergedStatus).toEqual(200)
|
||||||
|
expect(mergedSpecies.diet1).toEqual('b')
|
||||||
|
|
||||||
|
const mergedOccurrence = mergedSpecies.now_ls.find(ls => ls.lid === 24750)
|
||||||
|
expect(mergedOccurrence).toBeDefined()
|
||||||
|
expect(mergedOccurrence?.mni).toEqual(2)
|
||||||
|
expect(mergedOccurrence?.source_name).toEqual('Obsolete Source')
|
||||||
|
expect(mergedOccurrence?.mesowear).toEqual('mix')
|
||||||
|
|
||||||
|
const synonymNames = mergedSpecies.com_taxa_synonym.map(syn => `${syn.syn_genus_name} ${syn.syn_species_name}`)
|
||||||
|
expect(synonymNames).toContain('LegacyGenus legatus')
|
||||||
|
expect(synonymNames).toContain('MergeObsoleteGenus mergeObsolete')
|
||||||
|
|
||||||
|
const obsoleteFetch = await send(`species/${obsoleteId}`, 'GET')
|
||||||
|
expect(obsoleteFetch.status).toEqual(404)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('requires references', async () => {
|
||||||
|
const references = cloneSpeciesData().references
|
||||||
|
const acceptedCreate = await send<{ species_id: number }>('species', 'PUT', {
|
||||||
|
species: {
|
||||||
|
...cloneSpeciesData(),
|
||||||
|
genus_name: 'RefAcceptGenus',
|
||||||
|
species_name: 'refAccept',
|
||||||
|
unique_identifier: 'ref-accept',
|
||||||
|
now_ls: [],
|
||||||
|
comment: 'create accepted',
|
||||||
|
references,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const obsoleteCreate = await send<{ species_id: number }>('species', 'PUT', {
|
||||||
|
species: {
|
||||||
|
...cloneSpeciesData(),
|
||||||
|
genus_name: 'RefObsoleteGenus',
|
||||||
|
species_name: 'refObsolete',
|
||||||
|
unique_identifier: 'ref-obsolete',
|
||||||
|
now_ls: [],
|
||||||
|
comment: 'create obsolete',
|
||||||
|
references,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const mergeResult = await send('admin/species-merge', 'POST', {
|
||||||
|
obsoleteSpeciesId: obsoleteCreate.body.species_id,
|
||||||
|
acceptedSpeciesId: acceptedCreate.body.species_id,
|
||||||
|
selectedSpeciesFieldValues: {},
|
||||||
|
occurrenceFieldChoices: [],
|
||||||
|
addObsoleteAsSynonym: false,
|
||||||
|
addSourceNameToOccurrences: false,
|
||||||
|
comment: 'merge species',
|
||||||
|
references: [],
|
||||||
|
})
|
||||||
|
|
||||||
|
if (mergeResult.status !== 400) {
|
||||||
|
throw new Error(
|
||||||
|
`Expected 400 for missing references, got ${mergeResult.status}: ${JSON.stringify(mergeResult.body)}`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('summary includes non-taxonomic fields and omits taxonomic fields', async () => {
|
||||||
|
const references = cloneSpeciesData().references
|
||||||
|
|
||||||
|
const acceptedCreate = await send<{ species_id: number }>('species', 'PUT', {
|
||||||
|
species: {
|
||||||
|
...cloneSpeciesData(),
|
||||||
|
genus_name: 'SummaryAcceptGenus',
|
||||||
|
species_name: 'summaryAccept',
|
||||||
|
unique_identifier: 'summary-accept',
|
||||||
|
diet_description: 'accept diet description',
|
||||||
|
now_ls: [],
|
||||||
|
comment: 'create accepted',
|
||||||
|
references,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const obsoleteCreate = await send<{ species_id: number }>('species', 'PUT', {
|
||||||
|
species: {
|
||||||
|
...cloneSpeciesData(),
|
||||||
|
genus_name: 'SummaryObsoleteGenus',
|
||||||
|
species_name: 'summaryObsolete',
|
||||||
|
unique_identifier: 'summary-obsolete',
|
||||||
|
diet_description: 'obsolete diet description',
|
||||||
|
now_ls: [],
|
||||||
|
comment: 'create obsolete',
|
||||||
|
references,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const summaryResult = await send<{
|
||||||
|
speciesFieldChoices: Array<{ field: string }>
|
||||||
|
}>(
|
||||||
|
`admin/species-merge/summary?obsoleteId=${obsoleteCreate.body.species_id}&acceptedId=${acceptedCreate.body.species_id}`,
|
||||||
|
'GET'
|
||||||
|
)
|
||||||
|
|
||||||
|
if (summaryResult.status !== 200) {
|
||||||
|
throw new Error(`Summary failed: ${JSON.stringify(summaryResult.body)}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const fields = summaryResult.body.speciesFieldChoices.map(choice => choice.field)
|
||||||
|
expect(fields).toContain('diet_description')
|
||||||
|
expect(fields).not.toContain('order_name')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('merges numeric fields according to selection', async () => {
|
||||||
|
const references = cloneSpeciesData().references
|
||||||
|
|
||||||
|
const acceptedCreate = await send<{ species_id: number }>('species', 'PUT', {
|
||||||
|
species: {
|
||||||
|
...cloneSpeciesData(),
|
||||||
|
genus_name: 'BoolAcceptGenus',
|
||||||
|
species_name: 'boolAccept',
|
||||||
|
unique_identifier: 'bool-accept',
|
||||||
|
body_mass: 10,
|
||||||
|
now_ls: [],
|
||||||
|
comment: 'create accepted',
|
||||||
|
references,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const obsoleteCreate = await send<{ species_id: number }>('species', 'PUT', {
|
||||||
|
species: {
|
||||||
|
...cloneSpeciesData(),
|
||||||
|
genus_name: 'BoolObsoleteGenus',
|
||||||
|
species_name: 'boolObsolete',
|
||||||
|
unique_identifier: 'bool-obsolete',
|
||||||
|
body_mass: 99,
|
||||||
|
now_ls: [],
|
||||||
|
comment: 'create obsolete',
|
||||||
|
references,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const acceptedId = acceptedCreate.body.species_id
|
||||||
|
const obsoleteId = obsoleteCreate.body.species_id
|
||||||
|
|
||||||
|
const mergeResult = await send<MergeResponse>('admin/species-merge', 'POST', {
|
||||||
|
obsoleteSpeciesId: obsoleteId,
|
||||||
|
acceptedSpeciesId: acceptedId,
|
||||||
|
selectedSpeciesFieldValues: {
|
||||||
|
body_mass: 99,
|
||||||
|
},
|
||||||
|
occurrenceFieldChoices: [],
|
||||||
|
addObsoleteAsSynonym: false,
|
||||||
|
addSourceNameToOccurrences: false,
|
||||||
|
comment: 'merge boolean/numeric',
|
||||||
|
references,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (mergeResult.status !== 200) {
|
||||||
|
throw new Error(`Merge failed: ${JSON.stringify(mergeResult.body)}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const { body: mergedSpecies, status: mergedStatus } = await send<SpeciesDetailsType>(`species/${acceptedId}`, 'GET')
|
||||||
|
expect(mergedStatus).toEqual(200)
|
||||||
|
expect(mergedSpecies.body_mass).toEqual(99)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects non-admin users', async () => {
|
||||||
|
await login('testEr')
|
||||||
|
const result = await send('admin/species-merge', 'POST', {
|
||||||
|
obsoleteSpeciesId: 1,
|
||||||
|
acceptedSpeciesId: 2,
|
||||||
|
selectedSpeciesFieldValues: {},
|
||||||
|
occurrenceFieldChoices: [],
|
||||||
|
addObsoleteAsSynonym: false,
|
||||||
|
addSourceNameToOccurrences: false,
|
||||||
|
comment: 'merge species',
|
||||||
|
references: [],
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(result.status).toEqual(403)
|
||||||
|
expect(result.body).toEqual(noPermError)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -31,6 +31,7 @@ import { Role } from './../../frontend/src/shared/types'
|
|||||||
import { blockWriteRequests } from './middlewares/misc'
|
import { blockWriteRequests } from './middlewares/misc'
|
||||||
import testRouter from './routes/test'
|
import testRouter from './routes/test'
|
||||||
import occurrenceRouter from './routes/occurrence'
|
import occurrenceRouter from './routes/occurrence'
|
||||||
|
import speciesMergeRouter from './routes/speciesMerge'
|
||||||
|
|
||||||
const app = express()
|
const app = express()
|
||||||
|
|
||||||
@@ -57,6 +58,7 @@ app.use('/occurrence', occurrenceRouter)
|
|||||||
app.use('/locality', localityRouter)
|
app.use('/locality', localityRouter)
|
||||||
app.use('/locality-species', localitySpeciesRouter)
|
app.use('/locality-species', localitySpeciesRouter)
|
||||||
app.use('/species', speciesRouter)
|
app.use('/species', speciesRouter)
|
||||||
|
app.use('/admin/species-merge', requireOneOf([Role.Admin]), speciesMergeRouter)
|
||||||
app.use('/statistics', statisticsRouter)
|
app.use('/statistics', statisticsRouter)
|
||||||
app.use('/reference', referenceRouter)
|
app.use('/reference', referenceRouter)
|
||||||
app.use('/time-unit', timeUnitRouter)
|
app.use('/time-unit', timeUnitRouter)
|
||||||
|
|||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import { Router } from 'express'
|
||||||
|
import { getSpeciesMergeSummary, mergeSpecies, SpeciesMergeRequest } from '../services/speciesMerge'
|
||||||
|
|
||||||
|
const router = Router()
|
||||||
|
|
||||||
|
router.get('/summary', async (req, res) => {
|
||||||
|
const obsoleteIdRaw = req.query.obsoleteId
|
||||||
|
const acceptedIdRaw = req.query.acceptedId
|
||||||
|
|
||||||
|
const obsoleteId = typeof obsoleteIdRaw === 'string' ? parseInt(obsoleteIdRaw, 10) : NaN
|
||||||
|
const acceptedId = typeof acceptedIdRaw === 'string' ? parseInt(acceptedIdRaw, 10) : NaN
|
||||||
|
|
||||||
|
if (!Number.isFinite(obsoleteId) || !Number.isFinite(acceptedId)) {
|
||||||
|
return res.status(400).json({ message: 'obsoleteId and acceptedId must be valid integers' })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (obsoleteId === acceptedId) {
|
||||||
|
return res.status(400).json({ message: 'obsoleteId and acceptedId must be different' })
|
||||||
|
}
|
||||||
|
|
||||||
|
const summary = await getSpeciesMergeSummary(obsoleteId, acceptedId)
|
||||||
|
if (!summary) {
|
||||||
|
return res.status(404).json({ message: 'Species not found' })
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.status(200).send(summary)
|
||||||
|
})
|
||||||
|
|
||||||
|
router.post('/', async (req, res) => {
|
||||||
|
const payload = req.body as SpeciesMergeRequest
|
||||||
|
|
||||||
|
if (!payload || typeof payload !== 'object') {
|
||||||
|
return res.status(400).json({ message: 'Invalid payload' })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
typeof payload.obsoleteSpeciesId !== 'number' ||
|
||||||
|
typeof payload.acceptedSpeciesId !== 'number' ||
|
||||||
|
payload.obsoleteSpeciesId === payload.acceptedSpeciesId
|
||||||
|
) {
|
||||||
|
return res.status(400).json({ message: 'obsoleteSpeciesId and acceptedSpeciesId must be different integers' })
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (!req.user) {
|
||||||
|
return res.status(401).json({ message: 'User not authenticated' })
|
||||||
|
}
|
||||||
|
const result = await mergeSpecies(payload, req.user)
|
||||||
|
return res.status(200).json(result)
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : 'Merge failed'
|
||||||
|
return res.status(400).json({ message })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
export default router
|
||||||
@@ -0,0 +1,610 @@
|
|||||||
|
import { Reference, User } from '../../../frontend/src/shared/types'
|
||||||
|
import { referenceValidator } from '../../../frontend/src/shared/validators/validator'
|
||||||
|
import { getReferenceDetails } from './reference'
|
||||||
|
import { deleteSpecies } from './write/species'
|
||||||
|
import { fixBigInt } from '../utils/common'
|
||||||
|
import { getFieldsOfTables, nowDb } from '../utils/db'
|
||||||
|
import { NOW_DB_NAME } from '../utils/config'
|
||||||
|
import { WriteHandler } from './write/writeOperations/writeHandler'
|
||||||
|
|
||||||
|
type SpeciesSummary = {
|
||||||
|
species_id: number
|
||||||
|
order_name: string | null
|
||||||
|
family_name: string | null
|
||||||
|
genus_name: string | null
|
||||||
|
species_name: string | null
|
||||||
|
subclass_or_superorder_name: string | null
|
||||||
|
suborder_or_superfamily_name: string | null
|
||||||
|
subfamily_name: string | null
|
||||||
|
unique_identifier: string | null
|
||||||
|
localities: number
|
||||||
|
sv_length: string | null
|
||||||
|
body_mass: number | null
|
||||||
|
sd_size: string | null
|
||||||
|
sd_display: string | null
|
||||||
|
tshm: string | null
|
||||||
|
tht: string | null
|
||||||
|
crowntype: string | null
|
||||||
|
diet1: string | null
|
||||||
|
diet2: string | null
|
||||||
|
diet3: string | null
|
||||||
|
locomo1: string | null
|
||||||
|
locomo2: string | null
|
||||||
|
locomo3: string | null
|
||||||
|
sp_author?: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
type SpeciesFieldChoice = {
|
||||||
|
field: string
|
||||||
|
obsoleteValue: string | number | boolean | null
|
||||||
|
acceptedValue: string | number | boolean | null
|
||||||
|
defaultChoice: 'accepted' | 'obsolete'
|
||||||
|
}
|
||||||
|
|
||||||
|
export const OCCURRENCE_MERGE_FIELDS = [
|
||||||
|
'nis',
|
||||||
|
'pct',
|
||||||
|
'quad',
|
||||||
|
'mni',
|
||||||
|
'qua',
|
||||||
|
'id_status',
|
||||||
|
'orig_entry',
|
||||||
|
'source_name',
|
||||||
|
'body_mass',
|
||||||
|
'mesowear',
|
||||||
|
'mw_or_high',
|
||||||
|
'mw_or_low',
|
||||||
|
'mw_cs_sharp',
|
||||||
|
'mw_cs_round',
|
||||||
|
'mw_cs_blunt',
|
||||||
|
'mw_scale_min',
|
||||||
|
'mw_scale_max',
|
||||||
|
'mw_value',
|
||||||
|
'microwear',
|
||||||
|
'dc13_mean',
|
||||||
|
'dc13_n',
|
||||||
|
'dc13_max',
|
||||||
|
'dc13_min',
|
||||||
|
'dc13_stdev',
|
||||||
|
'do18_mean',
|
||||||
|
'do18_n',
|
||||||
|
'do18_max',
|
||||||
|
'do18_min',
|
||||||
|
'do18_stdev',
|
||||||
|
] as const
|
||||||
|
|
||||||
|
export type OccurrenceMergeField = (typeof OCCURRENCE_MERGE_FIELDS)[number]
|
||||||
|
|
||||||
|
type OccurrenceConflict = {
|
||||||
|
lid: number
|
||||||
|
localityName: string | null
|
||||||
|
country: string | null
|
||||||
|
obsolete: Record<OccurrenceMergeField, string | number | null>
|
||||||
|
accepted: Record<OccurrenceMergeField, string | number | null>
|
||||||
|
defaultChoice: Record<OccurrenceMergeField, 'accepted' | 'obsolete'>
|
||||||
|
}
|
||||||
|
|
||||||
|
type SpeciesMergeSummaryResponse = {
|
||||||
|
obsolete: SpeciesSummary
|
||||||
|
accepted: SpeciesSummary
|
||||||
|
speciesFieldChoices: SpeciesFieldChoice[]
|
||||||
|
occurrenceConflicts: OccurrenceConflict[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SpeciesMergeRequest = {
|
||||||
|
obsoleteSpeciesId: number
|
||||||
|
acceptedSpeciesId: number
|
||||||
|
selectedSpeciesFieldValues: Record<string, string | number | null>
|
||||||
|
occurrenceFieldChoices: Array<{
|
||||||
|
lid: number
|
||||||
|
fieldChoice: Record<OccurrenceMergeField, 'accepted' | 'obsolete'>
|
||||||
|
}>
|
||||||
|
addObsoleteAsSynonym: boolean
|
||||||
|
synonymComment?: string
|
||||||
|
addSourceNameToOccurrences: boolean
|
||||||
|
comment: string
|
||||||
|
references: Reference[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SpeciesMergeResponse = {
|
||||||
|
message: 'SPECIES MERGED SUCCESSFULLY'
|
||||||
|
suid: number
|
||||||
|
coordinator: string
|
||||||
|
editor: string
|
||||||
|
date: string
|
||||||
|
comment: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const SPECIES_SUMMARY_FIELDS = {
|
||||||
|
species_id: true,
|
||||||
|
order_name: true,
|
||||||
|
family_name: true,
|
||||||
|
genus_name: true,
|
||||||
|
species_name: true,
|
||||||
|
subclass_or_superorder_name: true,
|
||||||
|
suborder_or_superfamily_name: true,
|
||||||
|
subfamily_name: true,
|
||||||
|
unique_identifier: true,
|
||||||
|
sv_length: true,
|
||||||
|
body_mass: true,
|
||||||
|
sd_size: true,
|
||||||
|
sd_display: true,
|
||||||
|
tshm: true,
|
||||||
|
tht: true,
|
||||||
|
crowntype: true,
|
||||||
|
diet1: true,
|
||||||
|
diet2: true,
|
||||||
|
diet3: true,
|
||||||
|
locomo1: true,
|
||||||
|
locomo2: true,
|
||||||
|
locomo3: true,
|
||||||
|
sp_author: true,
|
||||||
|
} as const
|
||||||
|
|
||||||
|
const SPECIES_COPY_FIELDS = [
|
||||||
|
'taxonomic_status',
|
||||||
|
'common_name',
|
||||||
|
'sp_author',
|
||||||
|
'strain',
|
||||||
|
'gene',
|
||||||
|
'taxon_status',
|
||||||
|
'diet1',
|
||||||
|
'diet2',
|
||||||
|
'diet3',
|
||||||
|
'diet_description',
|
||||||
|
'rel_fib',
|
||||||
|
'selectivity',
|
||||||
|
'digestion',
|
||||||
|
'feedinghab1',
|
||||||
|
'feedinghab2',
|
||||||
|
'shelterhab1',
|
||||||
|
'shelterhab2',
|
||||||
|
'locomo1',
|
||||||
|
'locomo2',
|
||||||
|
'locomo3',
|
||||||
|
'hunt_forage',
|
||||||
|
'body_mass',
|
||||||
|
'brain_mass',
|
||||||
|
'sv_length',
|
||||||
|
'activity',
|
||||||
|
'sd_size',
|
||||||
|
'sd_display',
|
||||||
|
'tshm',
|
||||||
|
'symph_mob',
|
||||||
|
'relative_blade_length',
|
||||||
|
'tht',
|
||||||
|
'crowntype',
|
||||||
|
'microwear',
|
||||||
|
'horizodonty',
|
||||||
|
'cusp_shape',
|
||||||
|
'cusp_count_buccal',
|
||||||
|
'cusp_count_lingual',
|
||||||
|
'loph_count_lon',
|
||||||
|
'loph_count_trs',
|
||||||
|
'fct_al',
|
||||||
|
'fct_ol',
|
||||||
|
'fct_sf',
|
||||||
|
'fct_ot',
|
||||||
|
'fct_cm',
|
||||||
|
'mesowear',
|
||||||
|
'mw_or_high',
|
||||||
|
'mw_or_low',
|
||||||
|
'mw_cs_sharp',
|
||||||
|
'mw_cs_round',
|
||||||
|
'mw_cs_blunt',
|
||||||
|
'mw_scale_min',
|
||||||
|
'mw_scale_max',
|
||||||
|
'mw_value',
|
||||||
|
'pop_struc',
|
||||||
|
'sp_comment',
|
||||||
|
]
|
||||||
|
|
||||||
|
type SpeciesCopyField = (typeof SPECIES_COPY_FIELDS)[number]
|
||||||
|
|
||||||
|
const isEmptyValue = (value: unknown) => value === null || value === undefined || value === ''
|
||||||
|
|
||||||
|
const normalizeStringValue = (value: unknown) => (value === '' ? null : (value as string | number | null))
|
||||||
|
|
||||||
|
const normalizeBodyMass = (value: unknown) => {
|
||||||
|
if (typeof value === 'number') return Math.trunc(value)
|
||||||
|
if (value === null || value === undefined) return null
|
||||||
|
return value as number | null
|
||||||
|
}
|
||||||
|
|
||||||
|
const buildSpeciesDisplayName = (species: {
|
||||||
|
genus_name: string | null
|
||||||
|
species_name: string | null
|
||||||
|
unique_identifier: string | null
|
||||||
|
species_id: number
|
||||||
|
}) => {
|
||||||
|
const name = `${species.genus_name ?? ''} ${species.species_name ?? ''}`.trim()
|
||||||
|
if (name) return name
|
||||||
|
if (species.unique_identifier) return species.unique_identifier
|
||||||
|
return `${species.species_id}`
|
||||||
|
}
|
||||||
|
|
||||||
|
const appendSourceName = (sourceName: string | null, obsoleteName: string) => {
|
||||||
|
const trimmed = obsoleteName.trim()
|
||||||
|
if (!trimmed) return sourceName
|
||||||
|
if (!sourceName || sourceName.trim() === '') return trimmed
|
||||||
|
|
||||||
|
const normalizedSource = sourceName.toLowerCase()
|
||||||
|
if (normalizedSource.includes(trimmed.toLowerCase())) return sourceName
|
||||||
|
|
||||||
|
return `${sourceName}; ${trimmed}`
|
||||||
|
}
|
||||||
|
|
||||||
|
const getMergeWriteHandler = () => {
|
||||||
|
return new WriteHandler({
|
||||||
|
dbName: NOW_DB_NAME,
|
||||||
|
table: 'com_species',
|
||||||
|
idColumn: 'species_id',
|
||||||
|
allowedColumns: getFieldsOfTables(['com_species', 'now_ls', 'now_sau', 'now_sr', 'now_lau', 'com_taxa_synonym']),
|
||||||
|
type: 'update',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const buildOccurrenceSelect = () => {
|
||||||
|
const fields = OCCURRENCE_MERGE_FIELDS.reduce<Record<string, boolean>>((acc, field) => {
|
||||||
|
acc[field] = true
|
||||||
|
return acc
|
||||||
|
}, {})
|
||||||
|
|
||||||
|
return {
|
||||||
|
lid: true,
|
||||||
|
...fields,
|
||||||
|
now_loc: {
|
||||||
|
select: {
|
||||||
|
loc_name: true,
|
||||||
|
country: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const buildSpeciesSummary = async (speciesId: number): Promise<SpeciesSummary | null> => {
|
||||||
|
const species = await nowDb.com_species.findUnique({
|
||||||
|
where: { species_id: speciesId },
|
||||||
|
select: SPECIES_SUMMARY_FIELDS,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!species) return null
|
||||||
|
|
||||||
|
const localities = await nowDb.now_ls.count({ where: { species_id: speciesId } })
|
||||||
|
|
||||||
|
return {
|
||||||
|
...(species as SpeciesSummary),
|
||||||
|
localities,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const buildSpeciesCopyValues = async (
|
||||||
|
speciesId: number
|
||||||
|
): Promise<Record<SpeciesCopyField, string | number | boolean | null> | null> => {
|
||||||
|
const selectFields = SPECIES_COPY_FIELDS.reduce<Record<SpeciesCopyField, boolean>>((acc, field) => {
|
||||||
|
acc[field] = true
|
||||||
|
return acc
|
||||||
|
}, {})
|
||||||
|
|
||||||
|
const species = await nowDb.com_species.findUnique({
|
||||||
|
where: { species_id: speciesId },
|
||||||
|
select: selectFields,
|
||||||
|
})
|
||||||
|
|
||||||
|
return (species ?? null) as Record<SpeciesCopyField, string | number | boolean | null> | null
|
||||||
|
}
|
||||||
|
|
||||||
|
const buildSpeciesFieldChoices = (
|
||||||
|
obsolete: Record<SpeciesCopyField, string | number | boolean | null>,
|
||||||
|
accepted: Record<SpeciesCopyField, string | number | boolean | null>
|
||||||
|
): SpeciesFieldChoice[] => {
|
||||||
|
return SPECIES_COPY_FIELDS.map(field => {
|
||||||
|
const obsoleteValue = obsolete[field]
|
||||||
|
const acceptedValue = accepted[field]
|
||||||
|
|
||||||
|
const defaultChoice = isEmptyValue(acceptedValue) && !isEmptyValue(obsoleteValue) ? 'obsolete' : 'accepted'
|
||||||
|
|
||||||
|
return {
|
||||||
|
field,
|
||||||
|
obsoleteValue,
|
||||||
|
acceptedValue,
|
||||||
|
defaultChoice,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
type OccurrenceRow = Partial<Record<OccurrenceMergeField, string | number | null>> & {
|
||||||
|
now_loc?: { loc_name: string | null; country: string | null }
|
||||||
|
}
|
||||||
|
|
||||||
|
const buildOccurrenceConflict = (
|
||||||
|
lid: number,
|
||||||
|
obsoleteRow: OccurrenceRow,
|
||||||
|
acceptedRow: OccurrenceRow
|
||||||
|
): OccurrenceConflict => {
|
||||||
|
const obsoleteValues = {} as Record<OccurrenceMergeField, string | number | null>
|
||||||
|
const acceptedValues = {} as Record<OccurrenceMergeField, string | number | null>
|
||||||
|
const defaultChoice = {} as Record<OccurrenceMergeField, 'accepted' | 'obsolete'>
|
||||||
|
|
||||||
|
for (const field of OCCURRENCE_MERGE_FIELDS) {
|
||||||
|
const obsoleteValue = obsoleteRow[field]
|
||||||
|
const acceptedValue = acceptedRow[field]
|
||||||
|
|
||||||
|
obsoleteValues[field] = obsoleteValue ?? null
|
||||||
|
acceptedValues[field] = acceptedValue ?? null
|
||||||
|
defaultChoice[field] = isEmptyValue(acceptedValue) && !isEmptyValue(obsoleteValue) ? 'obsolete' : 'accepted'
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
lid,
|
||||||
|
localityName: acceptedRow.now_loc?.loc_name ?? obsoleteRow.now_loc?.loc_name ?? null,
|
||||||
|
country: acceptedRow.now_loc?.country ?? obsoleteRow.now_loc?.country ?? null,
|
||||||
|
obsolete: obsoleteValues,
|
||||||
|
accepted: acceptedValues,
|
||||||
|
defaultChoice,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getSpeciesMergeSummary = async (
|
||||||
|
obsoleteSpeciesId: number,
|
||||||
|
acceptedSpeciesId: number
|
||||||
|
): Promise<SpeciesMergeSummaryResponse | null> => {
|
||||||
|
const [obsoleteSummary, acceptedSummary] = await Promise.all([
|
||||||
|
buildSpeciesSummary(obsoleteSpeciesId),
|
||||||
|
buildSpeciesSummary(acceptedSpeciesId),
|
||||||
|
])
|
||||||
|
|
||||||
|
if (!obsoleteSummary || !acceptedSummary) return null
|
||||||
|
|
||||||
|
const [obsoleteCopyValues, acceptedCopyValues] = await Promise.all([
|
||||||
|
buildSpeciesCopyValues(obsoleteSpeciesId),
|
||||||
|
buildSpeciesCopyValues(acceptedSpeciesId),
|
||||||
|
])
|
||||||
|
|
||||||
|
if (!obsoleteCopyValues || !acceptedCopyValues) return null
|
||||||
|
|
||||||
|
const [obsoleteRows, acceptedRows] = await Promise.all([
|
||||||
|
nowDb.now_ls.findMany({
|
||||||
|
where: { species_id: obsoleteSpeciesId },
|
||||||
|
select: buildOccurrenceSelect(),
|
||||||
|
}),
|
||||||
|
nowDb.now_ls.findMany({
|
||||||
|
where: { species_id: acceptedSpeciesId },
|
||||||
|
select: buildOccurrenceSelect(),
|
||||||
|
}),
|
||||||
|
])
|
||||||
|
|
||||||
|
const obsoleteByLid = new Map<number, (typeof obsoleteRows)[number]>()
|
||||||
|
for (const row of obsoleteRows) obsoleteByLid.set(row.lid, row)
|
||||||
|
|
||||||
|
const acceptedByLid = new Map<number, (typeof acceptedRows)[number]>()
|
||||||
|
for (const row of acceptedRows) acceptedByLid.set(row.lid, row)
|
||||||
|
|
||||||
|
const occurrenceConflicts: OccurrenceConflict[] = []
|
||||||
|
for (const [lid, obsoleteRow] of obsoleteByLid.entries()) {
|
||||||
|
const acceptedRow = acceptedByLid.get(lid)
|
||||||
|
if (!acceptedRow) continue
|
||||||
|
|
||||||
|
occurrenceConflicts.push(buildOccurrenceConflict(lid, obsoleteRow, acceptedRow))
|
||||||
|
}
|
||||||
|
|
||||||
|
const speciesFieldChoices = buildSpeciesFieldChoices(obsoleteCopyValues, acceptedCopyValues)
|
||||||
|
|
||||||
|
const result: SpeciesMergeSummaryResponse = {
|
||||||
|
obsolete: obsoleteSummary,
|
||||||
|
accepted: acceptedSummary,
|
||||||
|
speciesFieldChoices,
|
||||||
|
occurrenceConflicts,
|
||||||
|
}
|
||||||
|
|
||||||
|
return JSON.parse(fixBigInt(result) ?? 'null') as SpeciesMergeSummaryResponse
|
||||||
|
}
|
||||||
|
|
||||||
|
const validateReferences = async (references: Reference[]) => {
|
||||||
|
const validationError = referenceValidator(references)
|
||||||
|
if (validationError) {
|
||||||
|
throw new Error(validationError)
|
||||||
|
}
|
||||||
|
|
||||||
|
const invalidReferences: number[] = []
|
||||||
|
for (const reference of references) {
|
||||||
|
if (typeof reference.rid !== 'number') {
|
||||||
|
invalidReferences.push(reference.rid as number)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
const result = await getReferenceDetails(reference.rid)
|
||||||
|
if (!result) invalidReferences.push(reference.rid)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (invalidReferences.length > 0) {
|
||||||
|
throw new Error(`References with ID(s) ${invalidReferences.join(', ')} do not exist`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const buildSynonymKey = (genus: string | null, species: string | null) =>
|
||||||
|
`${(genus ?? '').trim().toLowerCase()}|${(species ?? '').trim().toLowerCase()}`
|
||||||
|
|
||||||
|
const buildOccurrenceFieldChoiceMap = (occurrenceFieldChoices: SpeciesMergeRequest['occurrenceFieldChoices']) => {
|
||||||
|
const choiceMap = new Map<number, Record<OccurrenceMergeField, 'accepted' | 'obsolete'>>()
|
||||||
|
for (const choice of occurrenceFieldChoices) {
|
||||||
|
choiceMap.set(choice.lid, choice.fieldChoice)
|
||||||
|
}
|
||||||
|
return choiceMap
|
||||||
|
}
|
||||||
|
|
||||||
|
export const mergeSpecies = async (payload: SpeciesMergeRequest, user: User): Promise<SpeciesMergeResponse> => {
|
||||||
|
const {
|
||||||
|
obsoleteSpeciesId,
|
||||||
|
acceptedSpeciesId,
|
||||||
|
selectedSpeciesFieldValues,
|
||||||
|
occurrenceFieldChoices,
|
||||||
|
addObsoleteAsSynonym,
|
||||||
|
synonymComment,
|
||||||
|
addSourceNameToOccurrences,
|
||||||
|
comment,
|
||||||
|
references,
|
||||||
|
} = payload
|
||||||
|
|
||||||
|
if (!references || references.length === 0) {
|
||||||
|
throw new Error('References are required')
|
||||||
|
}
|
||||||
|
|
||||||
|
await validateReferences(references)
|
||||||
|
|
||||||
|
const [obsoleteSpecies, acceptedSpecies] = await Promise.all([
|
||||||
|
nowDb.com_species.findUnique({ where: { species_id: obsoleteSpeciesId } }),
|
||||||
|
nowDb.com_species.findUnique({ where: { species_id: acceptedSpeciesId } }),
|
||||||
|
])
|
||||||
|
|
||||||
|
if (!obsoleteSpecies || !acceptedSpecies) {
|
||||||
|
throw new Error('Species not found')
|
||||||
|
}
|
||||||
|
|
||||||
|
const obsoleteName = buildSpeciesDisplayName(obsoleteSpecies)
|
||||||
|
|
||||||
|
const [obsoleteOccurrences, acceptedOccurrences] = await Promise.all([
|
||||||
|
nowDb.now_ls.findMany({ where: { species_id: obsoleteSpeciesId } }),
|
||||||
|
nowDb.now_ls.findMany({ where: { species_id: acceptedSpeciesId } }),
|
||||||
|
])
|
||||||
|
|
||||||
|
const acceptedByLid = new Map<number, (typeof acceptedOccurrences)[number]>()
|
||||||
|
for (const row of acceptedOccurrences) acceptedByLid.set(row.lid, row)
|
||||||
|
|
||||||
|
const occurrenceChoiceMap = buildOccurrenceFieldChoiceMap(occurrenceFieldChoices)
|
||||||
|
|
||||||
|
const writeHandler = getMergeWriteHandler()
|
||||||
|
|
||||||
|
try {
|
||||||
|
await writeHandler.start()
|
||||||
|
writeHandler.idValue = acceptedSpeciesId
|
||||||
|
|
||||||
|
const allowedSpeciesFieldSet = new Set(SPECIES_COPY_FIELDS)
|
||||||
|
const speciesUpdates: Record<string, string | number | null> = {}
|
||||||
|
for (const [field, value] of Object.entries(selectedSpeciesFieldValues ?? {})) {
|
||||||
|
if (!allowedSpeciesFieldSet.has(field)) {
|
||||||
|
throw new Error(`Invalid species field: ${field}`)
|
||||||
|
}
|
||||||
|
if (value === undefined) continue
|
||||||
|
speciesUpdates[field] = normalizeStringValue(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Object.keys(speciesUpdates).length > 0) {
|
||||||
|
await writeHandler.updateObject('com_species', { species_id: acceptedSpeciesId, ...speciesUpdates }, [
|
||||||
|
'species_id',
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const obsoleteRow of obsoleteOccurrences) {
|
||||||
|
const acceptedRow = acceptedByLid.get(obsoleteRow.lid)
|
||||||
|
if (acceptedRow) {
|
||||||
|
const choice = occurrenceChoiceMap.get(obsoleteRow.lid)
|
||||||
|
if (!choice) {
|
||||||
|
throw new Error(`Missing occurrence field choices for lid ${obsoleteRow.lid}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const mergedRow = { ...acceptedRow } as Record<string, unknown>
|
||||||
|
for (const field of OCCURRENCE_MERGE_FIELDS) {
|
||||||
|
const fieldChoice = choice[field]
|
||||||
|
if (!fieldChoice) {
|
||||||
|
throw new Error(`Missing occurrence field choice for lid ${obsoleteRow.lid}, field ${field}`)
|
||||||
|
}
|
||||||
|
const selectedValue = fieldChoice === 'obsolete' ? obsoleteRow[field] : acceptedRow[field]
|
||||||
|
mergedRow[field] = normalizeStringValue(selectedValue)
|
||||||
|
}
|
||||||
|
|
||||||
|
mergedRow.source_name = addSourceNameToOccurrences
|
||||||
|
? appendSourceName(mergedRow.source_name as string | null, obsoleteName)
|
||||||
|
: mergedRow.source_name
|
||||||
|
mergedRow.body_mass = normalizeBodyMass(mergedRow.body_mass)
|
||||||
|
|
||||||
|
await writeHandler.updateObject('now_ls', mergedRow, ['lid', 'species_id'])
|
||||||
|
await writeHandler.deleteObject('now_ls', { lid: obsoleteRow.lid, species_id: obsoleteSpeciesId }, [
|
||||||
|
'lid',
|
||||||
|
'species_id',
|
||||||
|
])
|
||||||
|
} else {
|
||||||
|
const newRow = {
|
||||||
|
...obsoleteRow,
|
||||||
|
species_id: acceptedSpeciesId,
|
||||||
|
} as Record<string, unknown>
|
||||||
|
|
||||||
|
newRow.source_name = addSourceNameToOccurrences
|
||||||
|
? appendSourceName(newRow.source_name as string | null, obsoleteName)
|
||||||
|
: newRow.source_name
|
||||||
|
newRow.body_mass = normalizeBodyMass(newRow.body_mass)
|
||||||
|
|
||||||
|
await writeHandler.createObject('now_ls', newRow, ['lid', 'species_id'])
|
||||||
|
await writeHandler.deleteObject('now_ls', { lid: obsoleteRow.lid, species_id: obsoleteSpeciesId }, [
|
||||||
|
'lid',
|
||||||
|
'species_id',
|
||||||
|
])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const [obsoleteSynonyms, acceptedSynonyms] = await Promise.all([
|
||||||
|
nowDb.com_taxa_synonym.findMany({ where: { species_id: obsoleteSpeciesId } }),
|
||||||
|
nowDb.com_taxa_synonym.findMany({ where: { species_id: acceptedSpeciesId } }),
|
||||||
|
])
|
||||||
|
|
||||||
|
const acceptedSynonymKeys = new Set(
|
||||||
|
acceptedSynonyms.map(syn => buildSynonymKey(syn.syn_genus_name, syn.syn_species_name))
|
||||||
|
)
|
||||||
|
|
||||||
|
const synonymsToAdd = obsoleteSynonyms
|
||||||
|
.filter(syn => !acceptedSynonymKeys.has(buildSynonymKey(syn.syn_genus_name, syn.syn_species_name)))
|
||||||
|
.map(syn => ({
|
||||||
|
species_id: acceptedSpeciesId,
|
||||||
|
syn_genus_name: syn.syn_genus_name,
|
||||||
|
syn_species_name: syn.syn_species_name,
|
||||||
|
syn_comment: syn.syn_comment,
|
||||||
|
rowState: 'new' as const,
|
||||||
|
}))
|
||||||
|
|
||||||
|
if (addObsoleteAsSynonym) {
|
||||||
|
const key = buildSynonymKey(obsoleteSpecies.genus_name, obsoleteSpecies.species_name)
|
||||||
|
if (!acceptedSynonymKeys.has(key)) {
|
||||||
|
synonymsToAdd.push({
|
||||||
|
species_id: acceptedSpeciesId,
|
||||||
|
syn_genus_name: obsoleteSpecies.genus_name,
|
||||||
|
syn_species_name: obsoleteSpecies.species_name,
|
||||||
|
syn_comment: synonymComment ?? null,
|
||||||
|
rowState: 'new' as const,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (synonymsToAdd.length > 0) {
|
||||||
|
await writeHandler.applyListChanges('com_taxa_synonym', synonymsToAdd, ['synonym_id', 'species_id'])
|
||||||
|
}
|
||||||
|
|
||||||
|
await writeHandler.logUpdatesAndComplete(user.initials, comment, references)
|
||||||
|
} catch (error) {
|
||||||
|
await writeHandler.rollback()
|
||||||
|
await writeHandler.end()
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
|
||||||
|
await deleteSpecies(obsoleteSpeciesId, user)
|
||||||
|
|
||||||
|
const latestUpdate = await nowDb.now_sau.findFirst({
|
||||||
|
where: { species_id: acceptedSpeciesId },
|
||||||
|
orderBy: { suid: 'desc' },
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!latestUpdate) {
|
||||||
|
throw new Error('Merge succeeded but update log entry was not found')
|
||||||
|
}
|
||||||
|
|
||||||
|
const date = latestUpdate.sau_date
|
||||||
|
? latestUpdate.sau_date.toISOString().slice(0, 10)
|
||||||
|
: new Date().toISOString().slice(0, 10)
|
||||||
|
|
||||||
|
return {
|
||||||
|
message: 'SPECIES MERGED SUCCESSFULLY',
|
||||||
|
suid: latestUpdate.suid,
|
||||||
|
coordinator: latestUpdate.sau_coordinator,
|
||||||
|
editor: latestUpdate.sau_authorizer,
|
||||||
|
date,
|
||||||
|
comment,
|
||||||
|
}
|
||||||
|
}
|
||||||
+11
-1
@@ -3,7 +3,15 @@ import { logger } from './logger'
|
|||||||
import { PrismaClient as NowClient } from '../../prisma/generated/now_test_client'
|
import { PrismaClient as NowClient } from '../../prisma/generated/now_test_client'
|
||||||
import { PrismaClient as LogClient } from '../../prisma/generated/now_log_test_client/default'
|
import { PrismaClient as LogClient } from '../../prisma/generated/now_log_test_client/default'
|
||||||
import mariadb from 'mariadb'
|
import mariadb from 'mariadb'
|
||||||
import { MARIADB_HOST, MARIADB_PASSWORD, DB_CONNECTION_LIMIT, MARIADB_USER, RUNNING_ENV, LOG_DB_NAME } from './config'
|
import {
|
||||||
|
MARIADB_HOST,
|
||||||
|
MARIADB_PASSWORD,
|
||||||
|
DB_CONNECTION_LIMIT,
|
||||||
|
MARIADB_USER,
|
||||||
|
RUNNING_ENV,
|
||||||
|
LOG_DB_NAME,
|
||||||
|
MARIADB_PORT,
|
||||||
|
} from './config'
|
||||||
|
|
||||||
import { readFile } from 'fs/promises'
|
import { readFile } from 'fs/promises'
|
||||||
import { PathLike } from 'fs'
|
import { PathLike } from 'fs'
|
||||||
@@ -162,6 +170,7 @@ export const getFieldsOfTables = (tables: string[]) => {
|
|||||||
|
|
||||||
export const pool = mariadb.createPool({
|
export const pool = mariadb.createPool({
|
||||||
host: MARIADB_HOST,
|
host: MARIADB_HOST,
|
||||||
|
port: MARIADB_PORT,
|
||||||
password: MARIADB_PASSWORD,
|
password: MARIADB_PASSWORD,
|
||||||
user: MARIADB_USER,
|
user: MARIADB_USER,
|
||||||
connectionLimit: parseInt(DB_CONNECTION_LIMIT),
|
connectionLimit: parseInt(DB_CONNECTION_LIMIT),
|
||||||
@@ -218,6 +227,7 @@ export const resetTestDb = async () => {
|
|||||||
const createTestConnection = (dbName: string) => {
|
const createTestConnection = (dbName: string) => {
|
||||||
return mariadb.createConnection({
|
return mariadb.createConnection({
|
||||||
host: MARIADB_HOST,
|
host: MARIADB_HOST,
|
||||||
|
port: MARIADB_PORT,
|
||||||
password: process.env.MARIADB_ROOT_PASSWORD,
|
password: process.env.MARIADB_ROOT_PASSWORD,
|
||||||
user: 'root',
|
user: 'root',
|
||||||
checkDuplicate: false,
|
checkDuplicate: false,
|
||||||
|
|||||||
@@ -35,6 +35,16 @@ services:
|
|||||||
ports:
|
ports:
|
||||||
- 127.0.0.1:3306:3306
|
- 127.0.0.1:3306:3306
|
||||||
|
|
||||||
|
db-test:
|
||||||
|
container_name: nowdb-db-test
|
||||||
|
image: mariadb:11.4.3
|
||||||
|
env_file: .test.env
|
||||||
|
volumes:
|
||||||
|
- ./test_data/:/docker-entrypoint-initdb.d
|
||||||
|
- nowdb-db-test:/var/lib/mysql/data
|
||||||
|
ports:
|
||||||
|
- 127.0.0.1:3307:3306
|
||||||
|
|
||||||
phpmyadmin:
|
phpmyadmin:
|
||||||
restart: always
|
restart: always
|
||||||
image: phpmyadmin:5.2.1
|
image: phpmyadmin:5.2.1
|
||||||
@@ -48,3 +58,4 @@ services:
|
|||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
nowdb-db-dev:
|
nowdb-db-dev:
|
||||||
|
nowdb-db-test:
|
||||||
|
|||||||
@@ -102,6 +102,13 @@ This section summarizes the overall architecture, structure, and technologies of
|
|||||||
- Typical CI failures to avoid: unchecked Prettier drift, unused imports/variables (especially in tests), missing async return types or implicit `any`, and stale `eslint-disable` directives.
|
- Typical CI failures to avoid: unchecked Prettier drift, unused imports/variables (especially in tests), missing async return types or implicit `any`, and stale `eslint-disable` directives.
|
||||||
- Large fixtures or JSON assets (e.g., geodata) should remain excluded via `tsconfig.json`/`eslint.config.mjs` ignores; avoid importing them in type-checked code paths.
|
- Large fixtures or JSON assets (e.g., geodata) should remain excluded via `tsconfig.json`/`eslint.config.mjs` ignores; avoid importing them in type-checked code paths.
|
||||||
|
|
||||||
|
### Database Safety Guardrails (Mandatory)
|
||||||
|
|
||||||
|
- **Never** run test resets (`/test/reset-test-database`) against the development database.
|
||||||
|
- Treat `test_data/sqlfiles/` as **test-only** fixtures. Development data should come from `data/sqlfiles/` or an explicitly provided dump.
|
||||||
|
- If dev and test are on the same MariaDB container/port, **do not** proceed with resets. Plan a separation step (second container/port or distinct DB host) before running tests that reset data.
|
||||||
|
- Before any DB reset/restore, confirm the **active backend env** and **DB connection target** (host, port, db names) to avoid unintended overwrites.
|
||||||
|
|
||||||
### Frontend Unit-Test Planning Guardrails
|
### Frontend Unit-Test Planning Guardrails
|
||||||
|
|
||||||
- When a feature touches shared frontend infrastructure such as `DetailView`, `EditableTable`, `DetailTabTable`, `UnsavedChangesProvider`, Redux API slices, or route-aware components, include explicit test-harness tasks in the plan.
|
- When a feature touches shared frontend infrastructure such as `DetailView`, `EditableTable`, `DetailTabTable`, `UnsavedChangesProvider`, Redux API slices, or route-aware components, include explicit test-harness tasks in the plan.
|
||||||
|
|||||||
@@ -85,6 +85,9 @@ Implement **exactly one task** from an approved feature plan.
|
|||||||
### Backend API-test checklist (mandatory when backend code or backend API tests change)
|
### Backend API-test checklist (mandatory when backend code or backend API tests change)
|
||||||
|
|
||||||
- Prefer deterministic backend tests. Do not leave API tests dependent on live external services, network access, or unstable third-party result counts. Mock outbound integrations such as Geonames at the test boundary.
|
- Prefer deterministic backend tests. Do not leave API tests dependent on live external services, network access, or unstable third-party result counts. Mock outbound integrations such as Geonames at the test boundary.
|
||||||
|
- Never run `/test/reset-test-database` against the development database. Ensure test resets target an isolated test DB host/port.
|
||||||
|
- Treat `test_data/sqlfiles/` as **test-only** fixtures. Development data must not be overwritten by test resets.
|
||||||
|
- Before invoking any DB reset/restore command, verify the backend’s **active env** and **DB connection target** (host, port, db names).
|
||||||
- Use seeded ids and fixture values that are actually present in the checked-in test SQL. If a test depends on seed data, verify those ids/rows before changing expectations.
|
- Use seeded ids and fixture values that are actually present in the checked-in test SQL. If a test depends on seed data, verify those ids/rows before changing expectations.
|
||||||
- When changing shared backend write helpers, assume the blast radius is large. Check how the change affects generated ids, update-log entry creation, reference join rows, and metadata stripping across locality/species/time-unit/time-bound flows.
|
- When changing shared backend write helpers, assume the blast radius is large. Check how the change affects generated ids, update-log entry creation, reference join rows, and metadata stripping across locality/species/time-unit/time-bound flows.
|
||||||
- Strip helper-only metadata before generic persistence utilities. Route/test payloads may include fields such as `comment`, `references`, `up_bound`, `low_bound`, or UI-only helper objects that must not be treated as DB columns.
|
- Strip helper-only metadata before generic persistence utilities. Route/test payloads may include fields such as `comment`, `references`, `up_bound`, `low_bound`, or UI-only helper objects that must not be treated as DB columns.
|
||||||
|
|||||||
@@ -4,6 +4,14 @@
|
|||||||
|
|
||||||
The mariadb-container by default executes all .sql files it finds in the containers `/docker-entrypoint-initdb.d/` directory when the container is run the first time. The `data` directory is mounted there. It contains an initialization file (`restore_and_create.sql`) which will then restore the database from the sql-dumpfiles in `sqlfiles/`, and also create a user `'now_test'@'localhost'` with a password defined as MARIADB_PASSWORD in the .db.dev.env file for the database.
|
The mariadb-container by default executes all .sql files it finds in the containers `/docker-entrypoint-initdb.d/` directory when the container is run the first time. The `data` directory is mounted there. It contains an initialization file (`restore_and_create.sql`) which will then restore the database from the sql-dumpfiles in `sqlfiles/`, and also create a user `'now_test'@'localhost'` with a password defined as MARIADB_PASSWORD in the .db.dev.env file for the database.
|
||||||
|
|
||||||
|
**Dev vs Test database separation (important)**
|
||||||
|
|
||||||
|
- Development data is restored from `data/sqlfiles/` and runs in the `nowdb-db-dev` container on port `3306`.
|
||||||
|
- Test data is restored from `test_data/sqlfiles/` and runs in a separate `nowdb-db-test` container on port `3307`.
|
||||||
|
- Never run `/test/reset-test-database` against the dev database. It must target the test container only.
|
||||||
|
- Ensure `.test.env` points to port `3307` so API tests do not overwrite dev data.
|
||||||
|
- `resetTestDb` now uses `MARIADB_PORT`, so keep `.test.env` aligned with the test DB port.
|
||||||
|
|
||||||
**Accessing database inside docker**
|
**Accessing database inside docker**
|
||||||
|
|
||||||
`docker exec -it nowdb-db mariadb -u now_test -p --database now_test`
|
`docker exec -it nowdb-db mariadb -u now_test -p --database now_test`
|
||||||
|
|||||||
@@ -14,7 +14,8 @@ Requirements: npm and some modern installation of Docker engine with Docker comp
|
|||||||
|
|
||||||
1. `npm run setup` in project root to install all node modules, generate prisma-client and create all required config files (doesn't overwrite previous ones!).
|
1. `npm run setup` in project root to install all node modules, generate prisma-client and create all required config files (doesn't overwrite previous ones!).
|
||||||
2. If you wish to use the staging database, you need to copy the dump files into `data/sqlfiles/` directory. See example of how anonymized test data is in `test_data/sqlfiles/`
|
2. If you wish to use the staging database, you need to copy the dump files into `data/sqlfiles/` directory. See example of how anonymized test data is in `test_data/sqlfiles/`
|
||||||
3. `npm run dev` to run with the staging database or `npm run start:anon` to run with the anonymized small test-db included in the repository.
|
3. `npm run dev` starts **both** the dev database (`nowdb-db-dev`) and the test database (`nowdb-db-test`) so API tests can reset safely without overwriting dev data.
|
||||||
|
- You can also start just the test database with `npm run dev:db-test` and stop it with `npm run dev:db-test:down`.
|
||||||
4. Check if it worked: The docker logs should have a line saying database connection works. Open `localhost:5173` in browser to check if frontend works and shows data.
|
4. Check if it worked: The docker logs should have a line saying database connection works. Open `localhost:5173` in browser to check if frontend works and shows data.
|
||||||
|
|
||||||
**Errors while setting up**
|
**Errors while setting up**
|
||||||
|
|||||||
@@ -0,0 +1,68 @@
|
|||||||
|
# Species Merge
|
||||||
|
|
||||||
|
The Species Merge tool replaces all obsolete species ids with a selected accepted species id, while preserving
|
||||||
|
occurrence data, synonyms, and update logs. This flow is **Admin-only** and uses the same reference-selection and
|
||||||
|
save procedures as other DetailView updates.
|
||||||
|
|
||||||
|
## UI Flow Summary
|
||||||
|
|
||||||
|
1. Select an **Obsolete taxonomy** and an **Accepted taxonomy**.
|
||||||
|
2. Review the **Selected taxonomies** table (side-by-side values on the same row).
|
||||||
|
3. Choose whether to copy selected parameters from obsolete to accepted.
|
||||||
|
4. Decide whether to append the obsolete species name to locality-species `source_name` values.
|
||||||
|
5. Resolve occurrence conflicts (only shown when obsolete values exist and differ from accepted).
|
||||||
|
6. Decide whether to add the obsolete species as a synonym (optional comment).
|
||||||
|
7. Proceed to **Reference selection** (staging view) and save.
|
||||||
|
|
||||||
|
After a successful merge, the UI navigates to the accepted species detail page.
|
||||||
|
|
||||||
|
## Selected Taxonomies Table
|
||||||
|
|
||||||
|
- Displayed as a single table with columns: **Field**, **Obsolete taxonomy**, **Accepted taxonomy**.
|
||||||
|
- Field names are bold and rows are striped for quick comparison.
|
||||||
|
|
||||||
|
## Copy Selected Parameters
|
||||||
|
|
||||||
|
The copy list is intentionally tight:
|
||||||
|
- **Shows only** fields where the **obsolete value is non-empty** and **differs** from the accepted value.
|
||||||
|
- **Taxonomic fields are excluded** (see list below).
|
||||||
|
- Additional excluded fields: `class_name`, `used_morph`, `used_now`, `used_gene`, `sp_status`.
|
||||||
|
|
||||||
|
When the admin selects the obsolete value for a field, the accepted species is updated with that value during merge.
|
||||||
|
|
||||||
|
### Taxonomic Fields Excluded
|
||||||
|
|
||||||
|
- `order_name`
|
||||||
|
- `family_name`
|
||||||
|
- `genus_name`
|
||||||
|
- `species_name`
|
||||||
|
- `subclass_or_superorder_name`
|
||||||
|
- `suborder_or_superfamily_name`
|
||||||
|
- `subfamily_name`
|
||||||
|
- `unique_identifier`
|
||||||
|
|
||||||
|
## Occurrence Conflict Resolution
|
||||||
|
|
||||||
|
Occurrence conflicts are listed per locality only when:
|
||||||
|
- the obsolete value **exists**, and
|
||||||
|
- the obsolete value is **different** from the accepted value.
|
||||||
|
|
||||||
|
Selections are applied during merge to update accepted occurrence rows and migrate obsolete rows.
|
||||||
|
|
||||||
|
## References and Save
|
||||||
|
|
||||||
|
The merge flow uses the same staging/reference selection flow as other entity edits:
|
||||||
|
- References are **required**.
|
||||||
|
- Save triggers the merge and logs updates in the update log tables.
|
||||||
|
|
||||||
|
## API Endpoints
|
||||||
|
|
||||||
|
Backend endpoints are defined in `backend/src/routes/speciesMerge.ts` and implemented in
|
||||||
|
`backend/src/services/speciesMerge.ts`:
|
||||||
|
|
||||||
|
- `GET /admin/species-merge/summary?obsoleteId=<id>&acceptedId=<id>`
|
||||||
|
- `POST /admin/species-merge`
|
||||||
|
|
||||||
|
Payload and response shapes are defined in `frontend/src/shared/types` under `SpeciesMergeRequest`,
|
||||||
|
`SpeciesMergeSummary`, and `SpeciesMergeResponse`.
|
||||||
|
|
||||||
@@ -40,6 +40,7 @@ Users with the **EditUnrestricted** role are not limited by project membership w
|
|||||||
- Creating or updating a species (and synonyms) requires the **Admin**, **EditUnrestricted**, or **EditRestricted** roles.
|
- Creating or updating a species (and synonyms) requires the **Admin**, **EditUnrestricted**, or **EditRestricted** roles.
|
||||||
- Deleting a species requires the **Admin** or **EditUnrestricted** roles.
|
- Deleting a species requires the **Admin** or **EditUnrestricted** roles.
|
||||||
- Deleting a species synonym requires the **Admin**, **EditUnrestricted**, or **EditRestricted** roles.
|
- Deleting a species synonym requires the **Admin**, **EditUnrestricted**, or **EditRestricted** roles.
|
||||||
|
- Merging species requires the **Admin** role (via `/admin/species-merge`).
|
||||||
|
|
||||||
## References
|
## References
|
||||||
|
|
||||||
|
|||||||
@@ -20,10 +20,12 @@ export const WriteButton = <T,>({
|
|||||||
onWrite,
|
onWrite,
|
||||||
taxonomy,
|
taxonomy,
|
||||||
hasStagingMode = false,
|
hasStagingMode = false,
|
||||||
|
disabled = false,
|
||||||
}: {
|
}: {
|
||||||
onWrite: (editData: EditDataType<T>, setEditData: (editData: EditDataType<T>) => void) => Promise<void>
|
onWrite: (editData: EditDataType<T>, setEditData: (editData: EditDataType<T>) => void) => Promise<void>
|
||||||
taxonomy?: boolean
|
taxonomy?: boolean
|
||||||
hasStagingMode?: boolean
|
hasStagingMode?: boolean
|
||||||
|
disabled?: boolean
|
||||||
}) => {
|
}) => {
|
||||||
const { data, editData, setEditData, mode, setMode, validator, fieldsWithErrors, setFieldsWithErrors, isDirty } =
|
const { data, editData, setEditData, mode, setMode, validator, fieldsWithErrors, setFieldsWithErrors, isDirty } =
|
||||||
useDetailContext<T>()
|
useDetailContext<T>()
|
||||||
@@ -187,6 +189,7 @@ export const WriteButton = <T,>({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const handleWriteButtonClick = async () => {
|
const handleWriteButtonClick = async () => {
|
||||||
|
if (disabled) return
|
||||||
const finalize = async () => {
|
const finalize = async () => {
|
||||||
if (taxonomy) {
|
if (taxonomy) {
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
@@ -240,7 +243,7 @@ export const WriteButton = <T,>({
|
|||||||
>
|
>
|
||||||
<span>
|
<span>
|
||||||
<Button
|
<Button
|
||||||
disabled={Object.keys(fieldsWithErrors).length > 0 || !isDirty}
|
disabled={disabled || Object.keys(fieldsWithErrors).length > 0 || !isDirty}
|
||||||
id="write-button"
|
id="write-button"
|
||||||
sx={{ width: '20em' }}
|
sx={{ width: '20em' }}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ export const NavBar = () => {
|
|||||||
{ title: 'Projects', url: '/project' },
|
{ title: 'Projects', url: '/project' },
|
||||||
{ title: 'People', url: '/person' },
|
{ title: 'People', url: '/person' },
|
||||||
{ title: 'Email', url: '/email' },
|
{ title: 'Email', url: '/email' },
|
||||||
|
{ title: 'Merge Species', url: '/admin/merge-species' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -0,0 +1,822 @@
|
|||||||
|
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||||
|
import {
|
||||||
|
Alert,
|
||||||
|
Autocomplete,
|
||||||
|
Box,
|
||||||
|
Button,
|
||||||
|
Checkbox,
|
||||||
|
createFilterOptions,
|
||||||
|
Divider,
|
||||||
|
FormControlLabel,
|
||||||
|
Radio,
|
||||||
|
RadioGroup,
|
||||||
|
Stack as MuiStack,
|
||||||
|
Stack,
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableRow,
|
||||||
|
TextField,
|
||||||
|
Tooltip,
|
||||||
|
Typography,
|
||||||
|
} from '@mui/material'
|
||||||
|
import WarningAmberIcon from '@mui/icons-material/WarningAmber'
|
||||||
|
import {
|
||||||
|
DetailContextProvider,
|
||||||
|
modeOptionToMode,
|
||||||
|
ModeOptions,
|
||||||
|
ModeType,
|
||||||
|
} from '@/components/DetailView/Context/DetailContext'
|
||||||
|
import { FieldsWithErrorsType, OptionalRadioSelectionProps, TextFieldOptions } from '@/components/DetailView/DetailView'
|
||||||
|
import {
|
||||||
|
DropdownOption,
|
||||||
|
DropdownSelector,
|
||||||
|
DropdownSelectorWithSearch,
|
||||||
|
EditableTextField,
|
||||||
|
RadioSelector,
|
||||||
|
} from '@/components/DetailView/common/editingComponents'
|
||||||
|
import { ErrorBox, WriteButton } from '@/components/DetailView/components'
|
||||||
|
import { StagingView } from '@/components/DetailView/StagingView'
|
||||||
|
import { useUser } from '@/hooks/user'
|
||||||
|
import { PermissionDenied } from '@/components/PermissionDenied'
|
||||||
|
import { EditDataType, OccurrenceMergeField, Reference, Role, Species, SpeciesMergeSummary } from '@/shared/types'
|
||||||
|
import { useGetAllSpeciesQuery, useGetSpeciesMergeSummaryQuery, useMergeSpeciesMutation } from '@/redux/speciesReducer'
|
||||||
|
import { skipToken } from '@reduxjs/toolkit/query'
|
||||||
|
import { useNotify } from '@/hooks/notification'
|
||||||
|
import { PageContextProvider } from '@/components/Page'
|
||||||
|
import { useNavigate } from 'react-router-dom'
|
||||||
|
|
||||||
|
const speciesSummaryFields: Array<{ field: keyof SpeciesMergeSummary['obsolete']; label: string }> = [
|
||||||
|
{ field: 'species_id', label: 'species_id' },
|
||||||
|
{ field: 'order_name', label: 'order' },
|
||||||
|
{ field: 'family_name', label: 'family' },
|
||||||
|
{ field: 'genus_name', label: 'genus' },
|
||||||
|
{ field: 'species_name', label: 'species' },
|
||||||
|
{ field: 'subclass_or_superorder_name', label: 'subclass_or_superorder' },
|
||||||
|
{ field: 'suborder_or_superfamily_name', label: 'suborder_or_superfamily' },
|
||||||
|
{ field: 'subfamily_name', label: 'subfamily' },
|
||||||
|
{ field: 'unique_identifier', label: 'unique' },
|
||||||
|
{ field: 'localities', label: 'localities' },
|
||||||
|
{ field: 'sv_length', label: 'sv_length' },
|
||||||
|
{ field: 'body_mass', label: 'body_mass' },
|
||||||
|
{ field: 'sd_size', label: 'sd_size' },
|
||||||
|
{ field: 'sd_display', label: 'sd_display' },
|
||||||
|
{ field: 'tshm', label: 'tshm' },
|
||||||
|
{ field: 'tht', label: 'tht' },
|
||||||
|
{ field: 'crowntype', label: 'crown type' },
|
||||||
|
{ field: 'diet1', label: 'diet1' },
|
||||||
|
{ field: 'diet2', label: 'diet2' },
|
||||||
|
{ field: 'diet3', label: 'diet3' },
|
||||||
|
{ field: 'locomo1', label: 'locomo1' },
|
||||||
|
{ field: 'locomo2', label: 'locomo2' },
|
||||||
|
{ field: 'locomo3', label: 'locomo3' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const occurrenceFieldLabels: Record<string, string> = {
|
||||||
|
id_status: 'ID Status',
|
||||||
|
orig_entry: 'Additional Information',
|
||||||
|
source_name: 'Source Name',
|
||||||
|
nis: 'NIS',
|
||||||
|
pct: 'PCT',
|
||||||
|
quad: 'QUAD',
|
||||||
|
mni: 'MNI',
|
||||||
|
qua: 'QUA',
|
||||||
|
body_mass: 'Body Mass (g)',
|
||||||
|
mesowear: 'Mesowear',
|
||||||
|
mw_or_low: 'MW Low',
|
||||||
|
mw_or_high: 'MW High',
|
||||||
|
mw_cs_sharp: 'MW Sharp',
|
||||||
|
mw_cs_round: 'MW Round',
|
||||||
|
mw_cs_blunt: 'MW Blunt',
|
||||||
|
mw_scale_min: 'MW Scale Min',
|
||||||
|
mw_scale_max: 'MW Scale Max',
|
||||||
|
mw_value: 'MW Value',
|
||||||
|
microwear: 'Microwear',
|
||||||
|
dc13_mean: 'dC13 Mean',
|
||||||
|
dc13_n: 'dC13 n',
|
||||||
|
dc13_max: 'dC13 Max',
|
||||||
|
dc13_min: 'dC13 Min',
|
||||||
|
dc13_stdev: 'dC13 STDEV',
|
||||||
|
do18_mean: 'dO18 Mean',
|
||||||
|
do18_n: 'dO18 n',
|
||||||
|
do18_max: 'dO18 Max',
|
||||||
|
do18_min: 'dO18 Min',
|
||||||
|
do18_stdev: 'dO18 STDEV',
|
||||||
|
}
|
||||||
|
|
||||||
|
const taxonomyFields = new Set([
|
||||||
|
'order_name',
|
||||||
|
'family_name',
|
||||||
|
'genus_name',
|
||||||
|
'species_name',
|
||||||
|
'subclass_or_superorder_name',
|
||||||
|
'suborder_or_superfamily_name',
|
||||||
|
'subfamily_name',
|
||||||
|
'unique_identifier',
|
||||||
|
])
|
||||||
|
|
||||||
|
const isEmptyValue = (value: unknown) => value === null || value === undefined || value === ''
|
||||||
|
const normalizeValue = (value: unknown) => (isEmptyValue(value) ? '' : String(value))
|
||||||
|
const isSameValue = (left: unknown, right: unknown) => normalizeValue(left) === normalizeValue(right)
|
||||||
|
|
||||||
|
const resolveMergeErrorMessage = (error: unknown): string => {
|
||||||
|
if (error && typeof error === 'object' && 'data' in error) {
|
||||||
|
const data = (error as { data?: unknown }).data
|
||||||
|
if (typeof data === 'string') {
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
if (data && typeof data === 'object' && 'message' in data) {
|
||||||
|
const message = (data as { message?: unknown }).message
|
||||||
|
if (typeof message === 'string') {
|
||||||
|
return message
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error && typeof error === 'object' && 'message' in error) {
|
||||||
|
const message = (error as { message?: unknown }).message
|
||||||
|
if (typeof message === 'string') {
|
||||||
|
return message
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'Merge failed. Please review selections and try again.'
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatSpeciesLabel = (species: Species | null) => {
|
||||||
|
if (!species) return ''
|
||||||
|
const name = `${species.genus_name ?? ''} ${species.species_name ?? ''}`.trim()
|
||||||
|
const base = name || species.unique_identifier || 'Unnamed species'
|
||||||
|
const uniqueIdentifier =
|
||||||
|
species.unique_identifier && species.unique_identifier !== '-' && base !== species.unique_identifier
|
||||||
|
? ` (${species.unique_identifier})`
|
||||||
|
: ''
|
||||||
|
return `${species.species_id} ${base}${uniqueIdentifier}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SpeciesMergePage = () => {
|
||||||
|
const user = useUser()
|
||||||
|
const { notify } = useNotify()
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const { data: speciesList, isError: speciesError } = useGetAllSpeciesQuery()
|
||||||
|
const [mergeSpecies, { data: mergeResult }] = useMergeSpeciesMutation()
|
||||||
|
|
||||||
|
const [obsoleteSpecies, setObsoleteSpecies] = useState<Species | null>(null)
|
||||||
|
const [acceptedSpecies, setAcceptedSpecies] = useState<Species | null>(null)
|
||||||
|
const [copyParameters, setCopyParameters] = useState<'yes' | 'no'>('no')
|
||||||
|
const [addSourceName, setAddSourceName] = useState<'yes' | 'no'>('no')
|
||||||
|
const [addSynonym, setAddSynonym] = useState<'yes' | 'no'>('no')
|
||||||
|
const [synonymComment, setSynonymComment] = useState('')
|
||||||
|
const [readyToFinalize, setReadyToFinalize] = useState(false)
|
||||||
|
const [speciesFieldChoices, setSpeciesFieldChoices] = useState<Record<string, 'accepted' | 'obsolete'>>({})
|
||||||
|
const [mergeErrorMessage, setMergeErrorMessage] = useState<string | null>(null)
|
||||||
|
const [occurrenceChoices, setOccurrenceChoices] = useState<Record<number, Record<string, 'accepted' | 'obsolete'>>>(
|
||||||
|
{}
|
||||||
|
)
|
||||||
|
const [mode, setModeState] = useState<ModeType>(modeOptionToMode.edit)
|
||||||
|
const [fieldsWithErrors, setFieldsWithErrors] = useState<FieldsWithErrorsType>({})
|
||||||
|
const [mergeConfirmations, setMergeConfirmations] = useState({
|
||||||
|
reviewedDetails: false,
|
||||||
|
confirmRemoval: false,
|
||||||
|
})
|
||||||
|
const initializedMergeKeyRef = useRef<string | null>(null)
|
||||||
|
|
||||||
|
const obsoleteId = obsoleteSpecies?.species_id
|
||||||
|
const acceptedId = acceptedSpecies?.species_id
|
||||||
|
|
||||||
|
const summaryQueryArgs =
|
||||||
|
obsoleteId && acceptedId && obsoleteId !== acceptedId ? { obsoleteId, acceptedId } : skipToken
|
||||||
|
|
||||||
|
const {
|
||||||
|
data: summary,
|
||||||
|
isFetching: summaryLoading,
|
||||||
|
isError: summaryError,
|
||||||
|
} = useGetSpeciesMergeSummaryQuery(summaryQueryArgs)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!summary) return
|
||||||
|
|
||||||
|
const mergeKey = obsoleteId && acceptedId ? `${obsoleteId}-${acceptedId}` : null
|
||||||
|
if (!mergeKey || initializedMergeKeyRef.current === mergeKey) return
|
||||||
|
initializedMergeKeyRef.current = mergeKey
|
||||||
|
|
||||||
|
const fieldChoices: Record<string, 'accepted' | 'obsolete'> = {}
|
||||||
|
for (const choice of summary.speciesFieldChoices) {
|
||||||
|
fieldChoices[choice.field] = choice.defaultChoice
|
||||||
|
}
|
||||||
|
setSpeciesFieldChoices(fieldChoices)
|
||||||
|
|
||||||
|
const occurrenceChoiceMap: Record<number, Record<string, 'accepted' | 'obsolete'>> = {}
|
||||||
|
for (const conflict of summary.occurrenceConflicts) {
|
||||||
|
occurrenceChoiceMap[conflict.lid] = { ...conflict.defaultChoice }
|
||||||
|
}
|
||||||
|
setOccurrenceChoices(occurrenceChoiceMap)
|
||||||
|
}, [summary, obsoleteId, acceptedId])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (obsoleteId && acceptedId && obsoleteId === acceptedId) {
|
||||||
|
notify('Obsolete and accepted species must be different.', 'warning')
|
||||||
|
}
|
||||||
|
}, [obsoleteId, acceptedId, notify])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!obsoleteId || !acceptedId) {
|
||||||
|
initializedMergeKeyRef.current = null
|
||||||
|
}
|
||||||
|
}, [obsoleteId, acceptedId])
|
||||||
|
|
||||||
|
const speciesOptions = useMemo(() => speciesList ?? [], [speciesList])
|
||||||
|
const speciesFilterOptions = useMemo(
|
||||||
|
() =>
|
||||||
|
createFilterOptions<Species>({
|
||||||
|
stringify: option =>
|
||||||
|
[
|
||||||
|
option.species_id,
|
||||||
|
option.genus_name,
|
||||||
|
option.species_name,
|
||||||
|
option.unique_identifier,
|
||||||
|
option.family_name,
|
||||||
|
option.order_name,
|
||||||
|
]
|
||||||
|
.filter(value => value !== null && value !== undefined && value !== '')
|
||||||
|
.join(' '),
|
||||||
|
}),
|
||||||
|
[]
|
||||||
|
)
|
||||||
|
const mergeContextData = useMemo<{ references: Reference[]; comment: string }>(
|
||||||
|
() => ({ references: [], comment: '' }),
|
||||||
|
[]
|
||||||
|
)
|
||||||
|
|
||||||
|
const setMode = (newMode: ModeOptions) => {
|
||||||
|
setModeState(modeOptionToMode[newMode])
|
||||||
|
}
|
||||||
|
|
||||||
|
const mergeValidator = (
|
||||||
|
_editData: EditDataType<{ references: Reference[]; comment: string }>,
|
||||||
|
field: keyof EditDataType<{ references: Reference[]; comment: string }>
|
||||||
|
) => {
|
||||||
|
return { name: String(field), error: null }
|
||||||
|
}
|
||||||
|
|
||||||
|
const textField = (
|
||||||
|
field: keyof EditDataType<{ references: Reference[]; comment: string }>,
|
||||||
|
options?: TextFieldOptions
|
||||||
|
) => <EditableTextField<{ references: Reference[]; comment: string }> field={field} {...options} />
|
||||||
|
|
||||||
|
const dropdown = (
|
||||||
|
field: keyof EditDataType<{ references: Reference[]; comment: string }>,
|
||||||
|
options: Array<DropdownOption | string>,
|
||||||
|
name: string,
|
||||||
|
disabled?: boolean
|
||||||
|
) => (
|
||||||
|
<DropdownSelector<{ references: Reference[]; comment: string }>
|
||||||
|
field={field}
|
||||||
|
options={options}
|
||||||
|
name={name}
|
||||||
|
disabled={disabled}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
|
||||||
|
const dropdownWithSearch = (
|
||||||
|
field: keyof EditDataType<{ references: Reference[]; comment: string }>,
|
||||||
|
options: Array<DropdownOption | string>,
|
||||||
|
name: string,
|
||||||
|
disabled?: boolean,
|
||||||
|
label?: string
|
||||||
|
) => (
|
||||||
|
<DropdownSelectorWithSearch<{ references: Reference[]; comment: string }>
|
||||||
|
field={field}
|
||||||
|
options={options}
|
||||||
|
name={name}
|
||||||
|
disabled={disabled}
|
||||||
|
label={label}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
|
||||||
|
const radioSelection = (
|
||||||
|
field: keyof EditDataType<{ references: Reference[]; comment: string }>,
|
||||||
|
options: Array<DropdownOption | string>,
|
||||||
|
name: string,
|
||||||
|
optionalRadioSelectionProps?: OptionalRadioSelectionProps
|
||||||
|
) => (
|
||||||
|
<RadioSelector<{ references: Reference[]; comment: string }>
|
||||||
|
field={field}
|
||||||
|
options={options}
|
||||||
|
name={name}
|
||||||
|
{...optionalRadioSelectionProps}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
|
||||||
|
const bigTextField = (field: keyof EditDataType<{ references: Reference[]; comment: string }>) => (
|
||||||
|
<EditableTextField<{ references: Reference[]; comment: string }> field={field} type="text" big />
|
||||||
|
)
|
||||||
|
|
||||||
|
if (user.role !== Role.Admin) {
|
||||||
|
return (
|
||||||
|
<PermissionDenied title="Permission denied" message="Only administrators can access the Species Merge tool." />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSpeciesChoiceChange = (field: string, value: 'accepted' | 'obsolete') => {
|
||||||
|
setSpeciesFieldChoices(prev => ({ ...prev, [field]: value }))
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleOccurrenceChoiceChange = (lid: number, field: string, value: 'accepted' | 'obsolete') => {
|
||||||
|
setOccurrenceChoices(prev => ({
|
||||||
|
...prev,
|
||||||
|
[lid]: {
|
||||||
|
...(prev[lid] ?? {}),
|
||||||
|
[field]: value,
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleMergeWrite = async (editData: EditDataType<{ references: Reference[]; comment: string }>) => {
|
||||||
|
if (!summary || !obsoleteId || !acceptedId) return
|
||||||
|
|
||||||
|
if (!readyToFinalize) {
|
||||||
|
const message = 'Please confirm the merge action before finalizing.'
|
||||||
|
setMergeErrorMessage(message)
|
||||||
|
notify(message, 'warning')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!mergeConfirmations.reviewedDetails || !mergeConfirmations.confirmRemoval) {
|
||||||
|
const message = 'Please confirm the merge checklist before saving.'
|
||||||
|
setMergeErrorMessage(message)
|
||||||
|
notify(message, 'warning')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const cleanedReferences = (editData.references ?? [])
|
||||||
|
.filter(ref => !ref.rowState || ref.rowState !== 'removed')
|
||||||
|
.map(ref => {
|
||||||
|
const { rowState, ...rest } = ref as Reference & { rowState?: string }
|
||||||
|
return rest
|
||||||
|
})
|
||||||
|
|
||||||
|
if (cleanedReferences.length === 0) {
|
||||||
|
const message = 'Please add at least one reference before saving.'
|
||||||
|
setMergeErrorMessage(message)
|
||||||
|
notify(message, 'warning')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectedSpeciesFieldValues: Record<string, string | number | boolean | null> = {}
|
||||||
|
if (copyParameters === 'yes') {
|
||||||
|
for (const choice of summary.speciesFieldChoices) {
|
||||||
|
if (taxonomyFields.has(choice.field)) continue
|
||||||
|
if (isEmptyValue(choice.obsoleteValue) && isEmptyValue(choice.acceptedValue)) continue
|
||||||
|
const selection = speciesFieldChoices[choice.field] ?? choice.defaultChoice
|
||||||
|
selectedSpeciesFieldValues[choice.field] =
|
||||||
|
selection === 'obsolete' ? choice.obsoleteValue : choice.acceptedValue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const occurrenceFieldChoices = summary.occurrenceConflicts.map(conflict => ({
|
||||||
|
lid: conflict.lid,
|
||||||
|
fieldChoice: occurrenceChoices[conflict.lid] ?? conflict.defaultChoice,
|
||||||
|
}))
|
||||||
|
|
||||||
|
try {
|
||||||
|
setMergeErrorMessage(null)
|
||||||
|
await mergeSpecies({
|
||||||
|
obsoleteSpeciesId: obsoleteId,
|
||||||
|
acceptedSpeciesId: acceptedId,
|
||||||
|
selectedSpeciesFieldValues,
|
||||||
|
occurrenceFieldChoices,
|
||||||
|
addObsoleteAsSynonym: addSynonym === 'yes',
|
||||||
|
synonymComment: synonymComment.trim() || undefined,
|
||||||
|
addSourceNameToOccurrences: addSourceName === 'yes',
|
||||||
|
comment: editData.comment?.trim() || `Merging ${formatSpeciesLabel(obsoleteSpecies)} as synonym`,
|
||||||
|
references: cleanedReferences,
|
||||||
|
}).unwrap()
|
||||||
|
|
||||||
|
notify('Species merged successfully.')
|
||||||
|
setMode('read')
|
||||||
|
if (acceptedId) {
|
||||||
|
setTimeout(() => navigate(`/species/${acceptedId}`), 50)
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
const message = resolveMergeErrorMessage(error)
|
||||||
|
setMergeErrorMessage(message)
|
||||||
|
notify(message, 'error')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const detailContextState = {
|
||||||
|
data: mergeContextData,
|
||||||
|
mode,
|
||||||
|
setMode,
|
||||||
|
editData: mergeContextData as EditDataType<{ references: Reference[]; comment: string }>,
|
||||||
|
textField,
|
||||||
|
dropdown,
|
||||||
|
dropdownWithSearch,
|
||||||
|
radioSelection,
|
||||||
|
bigTextField,
|
||||||
|
validator: mergeValidator,
|
||||||
|
fieldsWithErrors,
|
||||||
|
setFieldsWithErrors,
|
||||||
|
}
|
||||||
|
|
||||||
|
const checklistComplete = mergeConfirmations.reviewedDetails && mergeConfirmations.confirmRemoval
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PageContextProvider
|
||||||
|
idFieldName="species_id"
|
||||||
|
viewName="species-merge"
|
||||||
|
createTitle={() => 'Merge Species'}
|
||||||
|
createSubtitle={() => ''}
|
||||||
|
editRights={{ edit: true }}
|
||||||
|
>
|
||||||
|
<DetailContextProvider contextState={detailContextState}>
|
||||||
|
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
|
||||||
|
<Typography variant="h4">Merge Species (Admin)</Typography>
|
||||||
|
|
||||||
|
{speciesError ? <Alert severity="error">Failed to load species list.</Alert> : null}
|
||||||
|
{summaryError ? <Alert severity="error">Failed to load merge summary.</Alert> : null}
|
||||||
|
{mergeErrorMessage ? <Alert severity="error">{mergeErrorMessage}</Alert> : null}
|
||||||
|
|
||||||
|
{!mode.staging ? (
|
||||||
|
<Stack direction={{ xs: 'column', md: 'row' }} spacing={2}>
|
||||||
|
<Autocomplete
|
||||||
|
options={speciesOptions}
|
||||||
|
filterOptions={speciesFilterOptions}
|
||||||
|
value={obsoleteSpecies}
|
||||||
|
onChange={(_event, value) => {
|
||||||
|
setObsoleteSpecies(value)
|
||||||
|
setReadyToFinalize(false)
|
||||||
|
setMergeErrorMessage(null)
|
||||||
|
}}
|
||||||
|
getOptionLabel={formatSpeciesLabel}
|
||||||
|
renderInput={params => <TextField {...params} label="Obsolete taxonomy" />}
|
||||||
|
sx={{ flex: 1 }}
|
||||||
|
/>
|
||||||
|
<Autocomplete
|
||||||
|
options={speciesOptions}
|
||||||
|
filterOptions={speciesFilterOptions}
|
||||||
|
value={acceptedSpecies}
|
||||||
|
onChange={(_event, value) => {
|
||||||
|
setAcceptedSpecies(value)
|
||||||
|
setReadyToFinalize(false)
|
||||||
|
setMergeErrorMessage(null)
|
||||||
|
}}
|
||||||
|
getOptionLabel={formatSpeciesLabel}
|
||||||
|
renderInput={params => <TextField {...params} label="Accepted taxonomy" />}
|
||||||
|
sx={{ flex: 1 }}
|
||||||
|
/>
|
||||||
|
</Stack>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{summaryLoading ? <Alert severity="info">Loading merge summary…</Alert> : null}
|
||||||
|
|
||||||
|
{summary && !mode.staging ? (
|
||||||
|
<>
|
||||||
|
<Divider />
|
||||||
|
<Typography variant="h6">Selected taxonomies</Typography>
|
||||||
|
<Stack direction={{ xs: 'column', md: 'row' }} spacing={2} sx={{ mb: 1 }}>
|
||||||
|
<Box sx={{ flex: 1 }}>
|
||||||
|
<Typography variant="subtitle1">Obsolete taxonomy</Typography>
|
||||||
|
<Typography>{formatSpeciesLabel(obsoleteSpecies)}</Typography>
|
||||||
|
</Box>
|
||||||
|
<Box sx={{ flex: 1 }}>
|
||||||
|
<Typography variant="subtitle1">Accepted taxonomy</Typography>
|
||||||
|
<Typography>{formatSpeciesLabel(acceptedSpecies)}</Typography>
|
||||||
|
</Box>
|
||||||
|
</Stack>
|
||||||
|
<Table size="small">
|
||||||
|
<TableHead>
|
||||||
|
<TableRow>
|
||||||
|
<TableCell />
|
||||||
|
<TableCell>Obsolete taxonomy</TableCell>
|
||||||
|
<TableCell>Accepted taxonomy</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
</TableHead>
|
||||||
|
<TableBody>
|
||||||
|
{speciesSummaryFields.map(field => (
|
||||||
|
<TableRow
|
||||||
|
key={`summary-${field.field}`}
|
||||||
|
sx={{ '&:nth-of-type(odd)': { backgroundColor: 'action.hover' } }}
|
||||||
|
>
|
||||||
|
<TableCell sx={{ fontWeight: 600 }}>{field.label}</TableCell>
|
||||||
|
<TableCell>{summary.obsolete?.[field.field] ?? '-'}</TableCell>
|
||||||
|
<TableCell>{summary.accepted?.[field.field] ?? '-'}</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
|
||||||
|
<Divider />
|
||||||
|
<Typography variant="h6">Copy selected parameters from obsolete to accepted?</Typography>
|
||||||
|
<RadioGroup
|
||||||
|
row
|
||||||
|
value={copyParameters}
|
||||||
|
onChange={event => setCopyParameters(event.target.value as 'yes' | 'no')}
|
||||||
|
>
|
||||||
|
<FormControlLabel value="yes" control={<Radio />} label="Yes" />
|
||||||
|
<FormControlLabel value="no" control={<Radio />} label="No" />
|
||||||
|
</RadioGroup>
|
||||||
|
|
||||||
|
{copyParameters === 'yes' ? (
|
||||||
|
<Table size="small">
|
||||||
|
<TableHead>
|
||||||
|
<TableRow>
|
||||||
|
<TableCell>Parameter</TableCell>
|
||||||
|
<TableCell>Obsolete</TableCell>
|
||||||
|
<TableCell>Accepted</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
</TableHead>
|
||||||
|
<TableBody>
|
||||||
|
{summary.speciesFieldChoices
|
||||||
|
.filter(
|
||||||
|
choice =>
|
||||||
|
!taxonomyFields.has(choice.field) &&
|
||||||
|
!isEmptyValue(choice.obsoleteValue) &&
|
||||||
|
!isSameValue(choice.obsoleteValue, choice.acceptedValue)
|
||||||
|
)
|
||||||
|
.map(choice => {
|
||||||
|
const selected = speciesFieldChoices[choice.field] ?? choice.defaultChoice
|
||||||
|
return (
|
||||||
|
<TableRow key={choice.field}>
|
||||||
|
<TableCell>{choice.field}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<FormControlLabel
|
||||||
|
control={
|
||||||
|
<Radio
|
||||||
|
checked={selected === 'obsolete'}
|
||||||
|
onChange={() => handleSpeciesChoiceChange(choice.field, 'obsolete')}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
label={isEmptyValue(choice.obsoleteValue) ? '-' : String(choice.obsoleteValue)}
|
||||||
|
/>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<FormControlLabel
|
||||||
|
control={
|
||||||
|
<Radio
|
||||||
|
checked={selected === 'accepted'}
|
||||||
|
onChange={() => handleSpeciesChoiceChange(choice.field, 'accepted')}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
label={isEmptyValue(choice.acceptedValue) ? '-' : String(choice.acceptedValue)}
|
||||||
|
/>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<Divider />
|
||||||
|
<Typography variant="h6">Source name</Typography>
|
||||||
|
<Typography>
|
||||||
|
Add {formatSpeciesLabel(obsoleteSpecies)} to the Source Name field in locality-species records when
|
||||||
|
merging locality data?
|
||||||
|
</Typography>
|
||||||
|
<RadioGroup
|
||||||
|
row
|
||||||
|
value={addSourceName}
|
||||||
|
onChange={event => setAddSourceName(event.target.value as 'yes' | 'no')}
|
||||||
|
>
|
||||||
|
<FormControlLabel value="yes" control={<Radio />} label="Yes" />
|
||||||
|
<FormControlLabel value="no" control={<Radio />} label="No" />
|
||||||
|
</RadioGroup>
|
||||||
|
|
||||||
|
<Divider />
|
||||||
|
<Typography variant="h6">Occurrence conflict resolution</Typography>
|
||||||
|
{summary.occurrenceConflicts.length === 0 ? (
|
||||||
|
<Typography>No conflicting occurrences found.</Typography>
|
||||||
|
) : (
|
||||||
|
summary.occurrenceConflicts
|
||||||
|
.map(conflict => {
|
||||||
|
const fields = (Object.keys(conflict.obsolete) as OccurrenceMergeField[]).filter(
|
||||||
|
field =>
|
||||||
|
!isEmptyValue(conflict.obsolete[field]) &&
|
||||||
|
!isSameValue(conflict.obsolete[field], conflict.accepted[field])
|
||||||
|
)
|
||||||
|
return { conflict, fields }
|
||||||
|
})
|
||||||
|
.filter(entry => entry.fields.length > 0)
|
||||||
|
.map(({ conflict, fields }) => (
|
||||||
|
<Box key={conflict.lid} sx={{ border: '1px solid #ccc', borderRadius: 1, p: 2, mb: 2 }}>
|
||||||
|
<Typography variant="subtitle1">
|
||||||
|
Locality {conflict.lid} - {conflict.localityName ?? 'Unknown'} ({conflict.country ?? '-'})
|
||||||
|
</Typography>
|
||||||
|
<Table size="small">
|
||||||
|
<TableHead>
|
||||||
|
<TableRow>
|
||||||
|
<TableCell>Field</TableCell>
|
||||||
|
<TableCell>Obsolete</TableCell>
|
||||||
|
<TableCell>Accepted</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
</TableHead>
|
||||||
|
<TableBody>
|
||||||
|
{fields.map(field => {
|
||||||
|
const selected = occurrenceChoices[conflict.lid]?.[field] ?? conflict.defaultChoice[field]
|
||||||
|
const obsoleteValue = conflict.obsolete[field]
|
||||||
|
const acceptedValue = conflict.accepted[field]
|
||||||
|
return (
|
||||||
|
<TableRow key={`${conflict.lid}-${field}`}>
|
||||||
|
<TableCell>{occurrenceFieldLabels[field] ?? field}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<FormControlLabel
|
||||||
|
control={
|
||||||
|
<Radio
|
||||||
|
checked={selected === 'obsolete'}
|
||||||
|
onChange={() => handleOccurrenceChoiceChange(conflict.lid, field, 'obsolete')}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
label={isEmptyValue(obsoleteValue) ? '-' : String(obsoleteValue)}
|
||||||
|
/>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<FormControlLabel
|
||||||
|
control={
|
||||||
|
<Radio
|
||||||
|
checked={selected === 'accepted'}
|
||||||
|
onChange={() => handleOccurrenceChoiceChange(conflict.lid, field, 'accepted')}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
label={isEmptyValue(acceptedValue) ? '-' : String(acceptedValue)}
|
||||||
|
/>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</Box>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Divider />
|
||||||
|
<Typography variant="h6">Add obsolete species as synonym?</Typography>
|
||||||
|
<RadioGroup row value={addSynonym} onChange={event => setAddSynonym(event.target.value as 'yes' | 'no')}>
|
||||||
|
<FormControlLabel value="yes" control={<Radio />} label="Yes" />
|
||||||
|
<FormControlLabel value="no" control={<Radio />} label="No" />
|
||||||
|
</RadioGroup>
|
||||||
|
{addSynonym === 'yes' ? (
|
||||||
|
<TextField
|
||||||
|
label="Synonym comment"
|
||||||
|
value={synonymComment}
|
||||||
|
onChange={event => setSynonymComment(event.target.value)}
|
||||||
|
fullWidth
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<Divider />
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
onClick={() => {
|
||||||
|
setReadyToFinalize(true)
|
||||||
|
setMergeErrorMessage(null)
|
||||||
|
setMode('staging-edit')
|
||||||
|
setMergeConfirmations({ reviewedDetails: false, confirmRemoval: false })
|
||||||
|
}}
|
||||||
|
disabled={!obsoleteId || !acceptedId || obsoleteId === acceptedId}
|
||||||
|
>
|
||||||
|
Remove {formatSpeciesLabel(obsoleteSpecies)} and keep {formatSpeciesLabel(acceptedSpecies)} (occurrence
|
||||||
|
data will be merged)
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{mergeResult ? (
|
||||||
|
<Alert severity="success">
|
||||||
|
<Typography>SPECIES MERGED SUCCESSFULLY</Typography>
|
||||||
|
<Typography>SUID {mergeResult.suid}</Typography>
|
||||||
|
<Typography>Coordinator {mergeResult.coordinator}</Typography>
|
||||||
|
<Typography>Editor {mergeResult.editor}</Typography>
|
||||||
|
<Typography>Date {mergeResult.date}</Typography>
|
||||||
|
<Typography>Comment {mergeResult.comment}</Typography>
|
||||||
|
</Alert>
|
||||||
|
) : null}
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{mode.staging ? (
|
||||||
|
<>
|
||||||
|
<Divider />
|
||||||
|
<Stack direction={{ xs: 'column', md: 'row' }} justifyContent="space-between" alignItems="center">
|
||||||
|
<Typography variant="h6">Reference Selection</Typography>
|
||||||
|
<Button
|
||||||
|
variant="outlined"
|
||||||
|
onClick={() => {
|
||||||
|
setMode('edit')
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Back to merge details
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
<Box sx={{ border: '1px solid #ccc', borderRadius: 1, p: 2 }}>
|
||||||
|
<Typography variant="subtitle1">Merge summary</Typography>
|
||||||
|
<Stack direction={{ xs: 'column', md: 'row' }} spacing={2} sx={{ mt: 1 }}>
|
||||||
|
<Box sx={{ flex: 1 }}>
|
||||||
|
<Typography variant="body2" color="text.secondary">
|
||||||
|
Obsolete species
|
||||||
|
</Typography>
|
||||||
|
<Typography>{formatSpeciesLabel(obsoleteSpecies)}</Typography>
|
||||||
|
</Box>
|
||||||
|
<Box sx={{ flex: 1 }}>
|
||||||
|
<Typography variant="body2" color="text.secondary">
|
||||||
|
Accepted species
|
||||||
|
</Typography>
|
||||||
|
<Typography>{formatSpeciesLabel(acceptedSpecies)}</Typography>
|
||||||
|
</Box>
|
||||||
|
</Stack>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Alert severity="warning">
|
||||||
|
You are about to merge and remove the obsolete species. This action will update occurrences and cannot
|
||||||
|
be easily undone.
|
||||||
|
</Alert>
|
||||||
|
|
||||||
|
<Box sx={{ border: '1px solid #ccc', borderRadius: 1, p: 2 }}>
|
||||||
|
<Typography variant="subtitle1">Merge checklist</Typography>
|
||||||
|
{!checklistComplete ? (
|
||||||
|
<MuiStack direction="row" spacing={1} alignItems="center" sx={{ mt: 1 }}>
|
||||||
|
<WarningAmberIcon fontSize="small" color="warning" />
|
||||||
|
<Typography variant="body2" color="warning.main">
|
||||||
|
Please complete the checklist before saving.
|
||||||
|
</Typography>
|
||||||
|
</MuiStack>
|
||||||
|
) : null}
|
||||||
|
<Stack>
|
||||||
|
<FormControlLabel
|
||||||
|
control={
|
||||||
|
<Checkbox
|
||||||
|
checked={mergeConfirmations.reviewedDetails}
|
||||||
|
onChange={event =>
|
||||||
|
setMergeConfirmations(prev => ({ ...prev, reviewedDetails: event.target.checked }))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
label={
|
||||||
|
<MuiStack direction="row" spacing={1} alignItems="center">
|
||||||
|
{!mergeConfirmations.reviewedDetails ? (
|
||||||
|
<WarningAmberIcon fontSize="small" color="warning" />
|
||||||
|
) : null}
|
||||||
|
<Typography color={mergeConfirmations.reviewedDetails ? 'text.primary' : 'error'}>
|
||||||
|
I reviewed the merge details and parameter selections.
|
||||||
|
</Typography>
|
||||||
|
</MuiStack>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<FormControlLabel
|
||||||
|
control={
|
||||||
|
<Checkbox
|
||||||
|
checked={mergeConfirmations.confirmRemoval}
|
||||||
|
onChange={event =>
|
||||||
|
setMergeConfirmations(prev => ({ ...prev, confirmRemoval: event.target.checked }))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
label={
|
||||||
|
<MuiStack direction="row" spacing={1} alignItems="center">
|
||||||
|
{!mergeConfirmations.confirmRemoval ? (
|
||||||
|
<WarningAmberIcon fontSize="small" color="warning" />
|
||||||
|
) : null}
|
||||||
|
<Typography color={mergeConfirmations.confirmRemoval ? 'text.primary' : 'error'}>
|
||||||
|
I understand the obsolete species will be removed after saving.
|
||||||
|
</Typography>
|
||||||
|
</MuiStack>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Stack>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<StagingView />
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
gap: 2,
|
||||||
|
alignItems: 'center',
|
||||||
|
flexWrap: 'wrap',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{!mergeConfirmations.reviewedDetails || !mergeConfirmations.confirmRemoval ? (
|
||||||
|
<Typography variant="body2" color="text.secondary">
|
||||||
|
Complete the checklist above to enable saving.
|
||||||
|
</Typography>
|
||||||
|
) : (
|
||||||
|
<span />
|
||||||
|
)}
|
||||||
|
<ErrorBox />
|
||||||
|
<Tooltip title={checklistComplete ? '' : 'Complete the checklist to enable saving.'}>
|
||||||
|
<span>
|
||||||
|
<WriteButton onWrite={handleMergeWrite} disabled={!checklistComplete} />
|
||||||
|
</span>
|
||||||
|
</Tooltip>
|
||||||
|
</Box>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
</Box>
|
||||||
|
</DetailContextProvider>
|
||||||
|
</PageContextProvider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default SpeciesMergePage
|
||||||
@@ -1,5 +1,13 @@
|
|||||||
import { api } from './api'
|
import { api } from './api'
|
||||||
import { EditDataType, Species, SpeciesDetailsType, SpeciesSynonym } from '@/shared/types'
|
import {
|
||||||
|
EditDataType,
|
||||||
|
Species,
|
||||||
|
SpeciesDetailsType,
|
||||||
|
SpeciesMergeRequest,
|
||||||
|
SpeciesMergeResponse,
|
||||||
|
SpeciesMergeSummary,
|
||||||
|
SpeciesSynonym,
|
||||||
|
} from '@/shared/types'
|
||||||
|
|
||||||
const speciesApi = api.injectEndpoints({
|
const speciesApi = api.injectEndpoints({
|
||||||
endpoints: builder => ({
|
endpoints: builder => ({
|
||||||
@@ -39,6 +47,24 @@ const speciesApi = api.injectEndpoints({
|
|||||||
invalidatesTags: (result, _error, species_id) =>
|
invalidatesTags: (result, _error, species_id) =>
|
||||||
typeof result !== 'undefined' ? [{ type: 'species', id: species_id }, 'specieslist', 'speciessynonyms'] : [],
|
typeof result !== 'undefined' ? [{ type: 'species', id: species_id }, 'specieslist', 'speciessynonyms'] : [],
|
||||||
}),
|
}),
|
||||||
|
getSpeciesMergeSummary: builder.query<SpeciesMergeSummary, { obsoleteId: number; acceptedId: number }>({
|
||||||
|
query: ({ obsoleteId, acceptedId }) => ({
|
||||||
|
url: `/admin/species-merge/summary`,
|
||||||
|
params: { obsoleteId, acceptedId },
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
mergeSpecies: builder.mutation<SpeciesMergeResponse, SpeciesMergeRequest>({
|
||||||
|
query: payload => ({
|
||||||
|
url: `/admin/species-merge`,
|
||||||
|
method: 'POST',
|
||||||
|
body: payload,
|
||||||
|
}),
|
||||||
|
invalidatesTags: (_result, _error, { acceptedSpeciesId }) => [
|
||||||
|
{ type: 'species', id: acceptedSpeciesId },
|
||||||
|
'specieslist',
|
||||||
|
'speciessynonyms',
|
||||||
|
],
|
||||||
|
}),
|
||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -48,4 +74,6 @@ export const {
|
|||||||
useGetSpeciesDetailsQuery,
|
useGetSpeciesDetailsQuery,
|
||||||
useEditSpeciesMutation,
|
useEditSpeciesMutation,
|
||||||
useDeleteSpeciesMutation,
|
useDeleteSpeciesMutation,
|
||||||
|
useGetSpeciesMergeSummaryQuery,
|
||||||
|
useMergeSpeciesMutation,
|
||||||
} = speciesApi
|
} = speciesApi
|
||||||
|
|||||||
@@ -67,6 +67,13 @@ const router = createBrowserRouter([
|
|||||||
return { Component: EmailPage }
|
return { Component: EmailPage }
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: 'admin/merge-species',
|
||||||
|
lazy: async () => {
|
||||||
|
const { SpeciesMergePage } = await import('../pages/admin/SpeciesMergePage')
|
||||||
|
return { Component: SpeciesMergePage }
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: 'login',
|
path: 'login',
|
||||||
lazy: async () => {
|
lazy: async () => {
|
||||||
|
|||||||
@@ -547,6 +547,157 @@ export type Reference = {
|
|||||||
title_secondary: string
|
title_secondary: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type OccurrenceMergeField =
|
||||||
|
| 'nis'
|
||||||
|
| 'pct'
|
||||||
|
| 'quad'
|
||||||
|
| 'mni'
|
||||||
|
| 'qua'
|
||||||
|
| 'id_status'
|
||||||
|
| 'orig_entry'
|
||||||
|
| 'source_name'
|
||||||
|
| 'body_mass'
|
||||||
|
| 'mesowear'
|
||||||
|
| 'mw_or_high'
|
||||||
|
| 'mw_or_low'
|
||||||
|
| 'mw_cs_sharp'
|
||||||
|
| 'mw_cs_round'
|
||||||
|
| 'mw_cs_blunt'
|
||||||
|
| 'mw_scale_min'
|
||||||
|
| 'mw_scale_max'
|
||||||
|
| 'mw_value'
|
||||||
|
| 'microwear'
|
||||||
|
| 'dc13_mean'
|
||||||
|
| 'dc13_n'
|
||||||
|
| 'dc13_max'
|
||||||
|
| 'dc13_min'
|
||||||
|
| 'dc13_stdev'
|
||||||
|
| 'do18_mean'
|
||||||
|
| 'do18_n'
|
||||||
|
| 'do18_max'
|
||||||
|
| 'do18_min'
|
||||||
|
| 'do18_stdev'
|
||||||
|
|
||||||
|
export type SpeciesMergeSummaryField = {
|
||||||
|
species_id: number
|
||||||
|
class_name: string | null
|
||||||
|
order_name: string | null
|
||||||
|
family_name: string | null
|
||||||
|
genus_name: string | null
|
||||||
|
species_name: string | null
|
||||||
|
subclass_or_superorder_name: string | null
|
||||||
|
suborder_or_superfamily_name: string | null
|
||||||
|
subfamily_name: string | null
|
||||||
|
unique_identifier: string | null
|
||||||
|
taxonomic_status: string | null
|
||||||
|
common_name: string | null
|
||||||
|
sp_author: string | null
|
||||||
|
strain: string | null
|
||||||
|
gene: string | null
|
||||||
|
taxon_status: string | null
|
||||||
|
diet_description: string | null
|
||||||
|
rel_fib: string | null
|
||||||
|
selectivity: string | null
|
||||||
|
digestion: string | null
|
||||||
|
feedinghab1: string | null
|
||||||
|
feedinghab2: string | null
|
||||||
|
shelterhab1: string | null
|
||||||
|
shelterhab2: string | null
|
||||||
|
hunt_forage: string | null
|
||||||
|
brain_mass: number | null
|
||||||
|
activity: string | null
|
||||||
|
symph_mob: string | null
|
||||||
|
relative_blade_length: number | null
|
||||||
|
microwear: string | null
|
||||||
|
horizodonty: string | null
|
||||||
|
cusp_shape: string | null
|
||||||
|
cusp_count_buccal: string | null
|
||||||
|
cusp_count_lingual: string | null
|
||||||
|
loph_count_lon: string | null
|
||||||
|
loph_count_trs: string | null
|
||||||
|
fct_al: string | null
|
||||||
|
fct_ol: string | null
|
||||||
|
fct_sf: string | null
|
||||||
|
fct_ot: string | null
|
||||||
|
fct_cm: string | 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
|
||||||
|
pop_struc: string | null
|
||||||
|
sp_status: boolean | null
|
||||||
|
used_morph: boolean | null
|
||||||
|
used_now: boolean | null
|
||||||
|
used_gene: boolean | null
|
||||||
|
sp_comment: string | null
|
||||||
|
localities: number
|
||||||
|
sv_length: string | null
|
||||||
|
body_mass: number | null
|
||||||
|
sd_size: string | null
|
||||||
|
sd_display: string | null
|
||||||
|
tshm: string | null
|
||||||
|
tht: string | null
|
||||||
|
crowntype: string | null
|
||||||
|
diet1: string | null
|
||||||
|
diet2: string | null
|
||||||
|
diet3: string | null
|
||||||
|
locomo1: string | null
|
||||||
|
locomo2: string | null
|
||||||
|
locomo3: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SpeciesMergeFieldChoice = {
|
||||||
|
field: string
|
||||||
|
obsoleteValue: string | number | boolean | null
|
||||||
|
acceptedValue: string | number | boolean | null
|
||||||
|
defaultChoice: 'accepted' | 'obsolete'
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SpeciesMergeConflict = {
|
||||||
|
lid: number
|
||||||
|
localityName: string | null
|
||||||
|
country: string | null
|
||||||
|
obsolete: Record<OccurrenceMergeField, string | number | null>
|
||||||
|
accepted: Record<OccurrenceMergeField, string | number | null>
|
||||||
|
defaultChoice: Record<OccurrenceMergeField, 'accepted' | 'obsolete'>
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SpeciesMergeSummary = {
|
||||||
|
obsolete: SpeciesMergeSummaryField
|
||||||
|
accepted: SpeciesMergeSummaryField
|
||||||
|
speciesFieldChoices: SpeciesMergeFieldChoice[]
|
||||||
|
occurrenceConflicts: SpeciesMergeConflict[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SpeciesMergeRequest = {
|
||||||
|
obsoleteSpeciesId: number
|
||||||
|
acceptedSpeciesId: number
|
||||||
|
selectedSpeciesFieldValues: Record<string, string | number | boolean | null>
|
||||||
|
occurrenceFieldChoices: Array<{
|
||||||
|
lid: number
|
||||||
|
fieldChoice: Record<OccurrenceMergeField, 'accepted' | 'obsolete'>
|
||||||
|
}>
|
||||||
|
addObsoleteAsSynonym: boolean
|
||||||
|
synonymComment?: string
|
||||||
|
addSourceNameToOccurrences: boolean
|
||||||
|
comment: string
|
||||||
|
references: Reference[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SpeciesMergeResponse = {
|
||||||
|
message: string
|
||||||
|
suid: number
|
||||||
|
coordinator: string
|
||||||
|
editor: string
|
||||||
|
date: string
|
||||||
|
comment: string
|
||||||
|
}
|
||||||
|
|
||||||
export type ReferenceOfUpdate = Prisma.ref_ref & {
|
export type ReferenceOfUpdate = Prisma.ref_ref & {
|
||||||
ref_authors: Prisma.ref_authors[]
|
ref_authors: Prisma.ref_authors[]
|
||||||
ref_journal: Prisma.ref_journal
|
ref_journal: Prisma.ref_journal
|
||||||
|
|||||||
@@ -0,0 +1,227 @@
|
|||||||
|
import { describe, expect, it, jest } from '@jest/globals'
|
||||||
|
import '@testing-library/jest-dom'
|
||||||
|
import { render, screen } from '@testing-library/react'
|
||||||
|
import { MemoryRouter } from 'react-router-dom'
|
||||||
|
import { SpeciesMergePage } from '@/pages/admin/SpeciesMergePage'
|
||||||
|
import { Role, Species, SpeciesMergeSummary, SpeciesMergeSummaryField, Reference } from '@/shared/types'
|
||||||
|
|
||||||
|
const mockUseUser = jest.fn()
|
||||||
|
const mockUseNotify = jest.fn()
|
||||||
|
const mockUseGetAllSpeciesQuery = jest.fn()
|
||||||
|
const mockUseGetSpeciesMergeSummaryQuery = jest.fn()
|
||||||
|
const mockUseMergeSpeciesMutation = jest.fn()
|
||||||
|
const mockUseGetAllReferencesQuery = jest.fn()
|
||||||
|
|
||||||
|
jest.mock('@/hooks/user', () => ({
|
||||||
|
useUser: () => mockUseUser(),
|
||||||
|
}))
|
||||||
|
|
||||||
|
jest.mock('@/hooks/notification', () => ({
|
||||||
|
useNotify: () => ({ notify: mockUseNotify }),
|
||||||
|
}))
|
||||||
|
|
||||||
|
jest.mock('@/redux/speciesReducer', () => ({
|
||||||
|
useGetAllSpeciesQuery: () => mockUseGetAllSpeciesQuery(),
|
||||||
|
useGetSpeciesMergeSummaryQuery: () => mockUseGetSpeciesMergeSummaryQuery(),
|
||||||
|
useMergeSpeciesMutation: () => mockUseMergeSpeciesMutation(),
|
||||||
|
}))
|
||||||
|
|
||||||
|
jest.mock('@/redux/referenceReducer', () => ({
|
||||||
|
useGetAllReferencesQuery: () => mockUseGetAllReferencesQuery(),
|
||||||
|
}))
|
||||||
|
|
||||||
|
const speciesFactory = (overrides: Partial<Species> = {}): Species => ({
|
||||||
|
species_id: 1,
|
||||||
|
order_name: 'Order',
|
||||||
|
family_name: 'Family',
|
||||||
|
genus_name: 'Genus',
|
||||||
|
species_name: 'species',
|
||||||
|
subclass_or_superorder_name: null,
|
||||||
|
suborder_or_superfamily_name: null,
|
||||||
|
subfamily_name: null,
|
||||||
|
unique_identifier: 'id',
|
||||||
|
taxonomic_status: null,
|
||||||
|
sv_length: null,
|
||||||
|
body_mass: 1,
|
||||||
|
sd_size: null,
|
||||||
|
sd_display: null,
|
||||||
|
tshm: null,
|
||||||
|
tht: null,
|
||||||
|
horizodonty: null,
|
||||||
|
crowntype: null,
|
||||||
|
cusp_shape: null,
|
||||||
|
cusp_count_buccal: null,
|
||||||
|
cusp_count_lingual: null,
|
||||||
|
loph_count_lon: null,
|
||||||
|
loph_count_trs: null,
|
||||||
|
fct_al: null,
|
||||||
|
fct_ol: null,
|
||||||
|
fct_sf: null,
|
||||||
|
fct_ot: null,
|
||||||
|
fct_cm: null,
|
||||||
|
microwear: null,
|
||||||
|
mesowear: null,
|
||||||
|
mw_or_high: 0,
|
||||||
|
mw_or_low: 0,
|
||||||
|
mw_cs_sharp: 0,
|
||||||
|
mw_cs_round: 0,
|
||||||
|
mw_cs_blunt: 0,
|
||||||
|
diet1: null,
|
||||||
|
diet2: null,
|
||||||
|
diet3: null,
|
||||||
|
locomo1: null,
|
||||||
|
locomo2: null,
|
||||||
|
locomo3: null,
|
||||||
|
sp_author: null,
|
||||||
|
sp_comment: null,
|
||||||
|
has_synonym: false,
|
||||||
|
has_no_locality: false,
|
||||||
|
synonyms: [],
|
||||||
|
...overrides,
|
||||||
|
})
|
||||||
|
|
||||||
|
const referenceFactory = (overrides: Partial<Reference> = {}): Reference => ({
|
||||||
|
rid: 100,
|
||||||
|
title_primary: 'Reference title',
|
||||||
|
title_secondary: '',
|
||||||
|
date_primary: 2000,
|
||||||
|
ref_authors: [{ au_num: 1, author_surname: 'Doe', author_initials: 'J' }],
|
||||||
|
ref_journal: { journal_title: 'Journal' },
|
||||||
|
ref_ref_type: { ref_type: 'Article' },
|
||||||
|
...overrides,
|
||||||
|
})
|
||||||
|
|
||||||
|
const summaryFieldFactory = (overrides: Partial<SpeciesMergeSummaryField> = {}): SpeciesMergeSummaryField => ({
|
||||||
|
species_id: 0,
|
||||||
|
class_name: null,
|
||||||
|
order_name: null,
|
||||||
|
family_name: null,
|
||||||
|
genus_name: null,
|
||||||
|
species_name: null,
|
||||||
|
subclass_or_superorder_name: null,
|
||||||
|
suborder_or_superfamily_name: null,
|
||||||
|
subfamily_name: null,
|
||||||
|
unique_identifier: null,
|
||||||
|
taxonomic_status: null,
|
||||||
|
common_name: null,
|
||||||
|
sp_author: null,
|
||||||
|
strain: null,
|
||||||
|
gene: null,
|
||||||
|
taxon_status: null,
|
||||||
|
diet_description: null,
|
||||||
|
rel_fib: null,
|
||||||
|
selectivity: null,
|
||||||
|
digestion: null,
|
||||||
|
feedinghab1: null,
|
||||||
|
feedinghab2: null,
|
||||||
|
shelterhab1: null,
|
||||||
|
shelterhab2: null,
|
||||||
|
hunt_forage: null,
|
||||||
|
brain_mass: null,
|
||||||
|
activity: null,
|
||||||
|
symph_mob: null,
|
||||||
|
relative_blade_length: null,
|
||||||
|
microwear: null,
|
||||||
|
horizodonty: null,
|
||||||
|
cusp_shape: null,
|
||||||
|
cusp_count_buccal: null,
|
||||||
|
cusp_count_lingual: null,
|
||||||
|
loph_count_lon: null,
|
||||||
|
loph_count_trs: null,
|
||||||
|
fct_al: null,
|
||||||
|
fct_ol: null,
|
||||||
|
fct_sf: null,
|
||||||
|
fct_ot: null,
|
||||||
|
fct_cm: 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,
|
||||||
|
pop_struc: null,
|
||||||
|
sp_status: null,
|
||||||
|
used_morph: null,
|
||||||
|
used_now: null,
|
||||||
|
used_gene: null,
|
||||||
|
sp_comment: null,
|
||||||
|
localities: 0,
|
||||||
|
sv_length: null,
|
||||||
|
body_mass: null,
|
||||||
|
sd_size: null,
|
||||||
|
sd_display: null,
|
||||||
|
tshm: null,
|
||||||
|
tht: null,
|
||||||
|
crowntype: null,
|
||||||
|
diet1: null,
|
||||||
|
diet2: null,
|
||||||
|
diet3: null,
|
||||||
|
locomo1: null,
|
||||||
|
locomo2: null,
|
||||||
|
locomo3: null,
|
||||||
|
...overrides,
|
||||||
|
})
|
||||||
|
|
||||||
|
const summaryFactory = (): SpeciesMergeSummary => ({
|
||||||
|
obsolete: summaryFieldFactory({
|
||||||
|
species_id: 1,
|
||||||
|
order_name: 'Order',
|
||||||
|
family_name: 'Family',
|
||||||
|
genus_name: 'Genus',
|
||||||
|
species_name: 'species',
|
||||||
|
unique_identifier: 'id',
|
||||||
|
localities: 1,
|
||||||
|
body_mass: 1,
|
||||||
|
}),
|
||||||
|
accepted: summaryFieldFactory({
|
||||||
|
species_id: 2,
|
||||||
|
order_name: 'Order',
|
||||||
|
family_name: 'Family',
|
||||||
|
genus_name: 'Genus',
|
||||||
|
species_name: 'species2',
|
||||||
|
unique_identifier: 'id2',
|
||||||
|
localities: 2,
|
||||||
|
body_mass: 2,
|
||||||
|
}),
|
||||||
|
speciesFieldChoices: [],
|
||||||
|
occurrenceConflicts: [],
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('SpeciesMergePage', () => {
|
||||||
|
it('blocks non-admin users', () => {
|
||||||
|
mockUseUser.mockReturnValue({ role: Role.ReadOnly })
|
||||||
|
mockUseGetAllSpeciesQuery.mockReturnValue({ data: [], isError: false })
|
||||||
|
mockUseGetAllReferencesQuery.mockReturnValue({ data: [], isError: false })
|
||||||
|
mockUseGetSpeciesMergeSummaryQuery.mockReturnValue({ data: undefined, isFetching: false, isError: false })
|
||||||
|
mockUseMergeSpeciesMutation.mockReturnValue([jest.fn(), { isLoading: false, data: undefined }])
|
||||||
|
|
||||||
|
render(
|
||||||
|
<MemoryRouter>
|
||||||
|
<SpeciesMergePage />
|
||||||
|
</MemoryRouter>
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(screen.getByText('Only administrators can access the Species Merge tool.')).toBeTruthy()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders the selection UI for admins', () => {
|
||||||
|
mockUseUser.mockReturnValue({ role: Role.Admin })
|
||||||
|
mockUseGetAllSpeciesQuery.mockReturnValue({ data: [speciesFactory()], isError: false })
|
||||||
|
mockUseGetAllReferencesQuery.mockReturnValue({ data: [referenceFactory()], isError: false })
|
||||||
|
mockUseGetSpeciesMergeSummaryQuery.mockReturnValue({ data: summaryFactory(), isFetching: false, isError: false })
|
||||||
|
mockUseMergeSpeciesMutation.mockReturnValue([jest.fn(), { isLoading: false, data: undefined }])
|
||||||
|
|
||||||
|
render(
|
||||||
|
<MemoryRouter>
|
||||||
|
<SpeciesMergePage />
|
||||||
|
</MemoryRouter>
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(screen.getByText('Merge Species (Admin)')).toBeTruthy()
|
||||||
|
expect(screen.getByLabelText('Obsolete taxonomy')).toBeTruthy()
|
||||||
|
expect(screen.getByLabelText('Accepted taxonomy')).toBeTruthy()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -10,6 +10,8 @@
|
|||||||
"start:anon": "docker compose -f docker-compose.anon.yml up",
|
"start:anon": "docker compose -f docker-compose.anon.yml up",
|
||||||
"start:anon:api": "docker compose -f docker-compose.anon.yml up db-anon",
|
"start:anon:api": "docker compose -f docker-compose.anon.yml up db-anon",
|
||||||
"dev": "docker compose -f docker-compose.dev.yml up",
|
"dev": "docker compose -f docker-compose.dev.yml up",
|
||||||
|
"dev:db-test": "docker compose -f docker-compose.dev.yml up -d db-test",
|
||||||
|
"dev:db-test:down": "docker compose -f docker-compose.dev.yml stop db-test",
|
||||||
"dev:down": "docker compose -f docker-compose.dev.yml down -v",
|
"dev:down": "docker compose -f docker-compose.dev.yml down -v",
|
||||||
"anon:down": "docker compose -f docker-compose.anon.yml down -v",
|
"anon:down": "docker compose -f docker-compose.anon.yml down -v",
|
||||||
"check": "echo \"Running checks for both services and cypress tests...\" && npm run tsc && npm run lint && echo \"All good! :)\"",
|
"check": "echo \"Running checks for both services and cypress tests...\" && npm run tsc && npm run lint && echo \"All good! :)\"",
|
||||||
|
|||||||
Reference in New Issue
Block a user