Improve species merge UX and docs

This commit is contained in:
karilint
2026-04-14 15:11:05 +03:00
parent fd463566a2
commit 08cfb1bd7b
21 changed files with 2354 additions and 4 deletions
+7
View File
@@ -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.
+325
View File
@@ -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)
})
})
+2
View File
@@ -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)
+56
View File
@@ -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
+610
View File
@@ -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
View File
@@ -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,
+11
View File
@@ -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:
+7
View File
@@ -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.
+8
View File
@@ -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`
+2 -1
View File
@@ -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`.
+1
View File
@@ -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={() => {
+1
View File
@@ -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
+29 -1
View File
@@ -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
+7
View File
@@ -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 () => {
+151
View File
@@ -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()
})
})
+2
View File
@@ -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! :)\"",