Enable editable occurrence core and wear tab controls

This commit is contained in:
Kari Lintulaakso
2026-03-09 12:43:25 +02:00
parent 0900f178f1
commit bbeadbb0a4
7 changed files with 239 additions and 51 deletions
@@ -6,10 +6,17 @@ import { OccurrenceCoreTab } from './Tabs/OccurrenceCoreTab'
import { OccurrenceWearTab } from './Tabs/OccurrenceWearTab'
import { OccurrenceIsotopeTab } from './Tabs/OccurrenceIsotopeTab'
import { useOccurrenceDetails } from '@/hooks/useOccurrenceDetails'
import { OccurrenceDetailsType } from '@/shared/types'
import { EditDataType, EditableOccurrenceData, OccurrenceDetailsType, ValidationErrors } from '@/shared/types'
import { validateOccurrence } from '@/shared/validators/occurrence'
import { useNotify } from '@/hooks/notification'
import { ValidationObject } from '@/shared/validators/validator'
const validateOccurrence = (): ValidationObject => ({ error: null, name: '' })
const validateOccurrenceDetail = (
editData: EditDataType<OccurrenceDetailsType>,
fieldName: keyof EditDataType<OccurrenceDetailsType>
): ValidationObject => {
return validateOccurrence(editData as EditableOccurrenceData, fieldName as keyof EditableOccurrenceData)
}
const emptyOccurrence: OccurrenceDetailsType = {
lid: 0,
@@ -62,13 +69,26 @@ export const OccurrenceDetails = () => {
const { lid, speciesId } = useParams()
const parsedLid = lid ? parseInt(lid, 10) : null
const parsedSpeciesId = speciesId ? parseInt(speciesId, 10) : null
const { occurrence, isLoading, isError } = useOccurrenceDetails(parsedLid, parsedSpeciesId)
const { occurrence, isLoading, isSaving, isError, saveOccurrence } = useOccurrenceDetails(parsedLid, parsedSpeciesId)
const { notify } = useNotify()
if (isError) return <div>Error loading occurrence data</div>
if (isLoading || !occurrence) return <CircularProgress />
if (isLoading || isSaving || !occurrence) return <CircularProgress />
document.title = `Occurrence - ${occurrence.lid}/${occurrence.species_id}`
const onWrite = async (editData: EditDataType<OccurrenceDetailsType>) => {
try {
await saveOccurrence(editData)
notify('Saved occurrence entry successfully.')
} catch (e) {
const error = e as ValidationErrors
const message =
error.data?.map(validationError => validationError.error).join(', ') ?? 'Could not save occurrence entry.'
notify(message, 'error')
}
}
const tabs: TabType[] = [
{ title: 'Core', content: <OccurrenceCoreTab /> },
{ title: 'Wear', content: <OccurrenceWearTab /> },
@@ -83,7 +103,8 @@ export const OccurrenceDetails = () => {
<DetailView<OccurrenceDetailsType>
tabs={tabs}
data={occurrence ?? emptyOccurrence}
validator={validateOccurrence}
validator={validateOccurrenceDetail}
onWrite={onWrite}
/>
)
}
@@ -1,10 +1,12 @@
import { useDetailContext } from '@/components/DetailView/Context/DetailContext'
import { ArrayFrame, HalfFrames } from '@/components/DetailView/common/tabLayoutHelpers'
import { OccurrenceDetailsType } from '@/shared/types'
import { idStatusOptions, quantityOptions } from '../constants'
const toText = (value: string | number | null) => (value === null || value === '' ? '-' : String(value))
const toText = (value: string | number | null | undefined) =>
value === null || value === undefined || value === '' ? '-' : String(value)
const quantityLabel = (value: string | null) => {
const quantityLabel = (value: string | null | undefined) => {
const quantityMap: Record<string, string> = {
a: 'Abundant',
c: 'Common',
@@ -17,7 +19,8 @@ const quantityLabel = (value: string | null) => {
}
export const OccurrenceCoreTab = () => {
const { data } = useDetailContext<OccurrenceDetailsType>()
const { data, editData, mode, textField, dropdown } = useDetailContext<OccurrenceDetailsType>()
const sourceData = mode.read ? data : editData
return (
<>
@@ -31,25 +34,31 @@ export const OccurrenceCoreTab = () => {
['Species ID', toText(data.species_id)],
['Genus', toText(data.genus_name)],
['Species', toText(data.species_name)],
['ID status', toText(data.id_status)],
['Original entry', toText(data.orig_entry)],
['Source name', toText(data.source_name)],
[
'ID status',
mode.read ? toText(sourceData.id_status) : dropdown('id_status', idStatusOptions, 'ID status'),
],
['Original entry', mode.read ? toText(sourceData.orig_entry) : textField('orig_entry', { type: 'text' })],
['Source name', mode.read ? toText(sourceData.source_name) : textField('source_name', { type: 'text' })],
]}
/>,
<ArrayFrame
key="counts"
title="Occurrence counts"
array={[
['NISP', toText(data.nis)],
['Percent', toText(data.pct)],
['Quadrate', toText(data.quad)],
['MNI', toText(data.mni)],
['Quantity', quantityLabel(data.qua)],
['NISP', mode.read ? toText(sourceData.nis) : textField('nis', { type: 'number' })],
['Percent', mode.read ? toText(sourceData.pct) : textField('pct', { type: 'number' })],
['Quadrate', mode.read ? toText(sourceData.quad) : textField('quad', { type: 'number' })],
['MNI', mode.read ? toText(sourceData.mni) : textField('mni', { type: 'number' })],
['Quantity', mode.read ? quantityLabel(sourceData.qua) : dropdown('qua', quantityOptions, 'Quantity')],
]}
/>,
]}
</HalfFrames>
<ArrayFrame array={[['Body mass', toText(data.body_mass)]]} title="Size" />
<ArrayFrame
array={[['Body mass', mode.read ? toText(sourceData.body_mass) : textField('body_mass', { type: 'number' })]]}
title="Size"
/>
</>
)
}
@@ -1,11 +1,14 @@
import { useDetailContext } from '@/components/DetailView/Context/DetailContext'
import { ArrayFrame, HalfFrames } from '@/components/DetailView/common/tabLayoutHelpers'
import { OccurrenceDetailsType } from '@/shared/types'
import { mesowearOptions, microwearOptions } from '../constants'
const toText = (value: string | number | null) => (value === null || value === '' ? '-' : String(value))
const toText = (value: string | number | null | undefined) =>
value === null || value === undefined || value === '' ? '-' : String(value)
export const OccurrenceWearTab = () => {
const { data } = useDetailContext<OccurrenceDetailsType>()
const { data, editData, mode, textField, dropdown } = useDetailContext<OccurrenceDetailsType>()
const sourceData = mode.read ? data : editData
return (
<HalfFrames>
@@ -14,22 +17,31 @@ export const OccurrenceWearTab = () => {
key="mesowear"
title="Mesowear"
array={[
['Mesowear', toText(data.mesowear)],
['MW OR High', toText(data.mw_or_high)],
['MW OR Low', toText(data.mw_or_low)],
['MW CS Sharp', toText(data.mw_cs_sharp)],
['MW CS Round', toText(data.mw_cs_round)],
['MW CS Blunt', toText(data.mw_cs_blunt)],
['Mesowear', mode.read ? toText(sourceData.mesowear) : dropdown('mesowear', mesowearOptions, 'Mesowear')],
['MW OR High', mode.read ? toText(sourceData.mw_or_high) : textField('mw_or_high', { type: 'number' })],
['MW OR Low', mode.read ? toText(sourceData.mw_or_low) : textField('mw_or_low', { type: 'number' })],
['MW CS Sharp', mode.read ? toText(sourceData.mw_cs_sharp) : textField('mw_cs_sharp', { type: 'number' })],
['MW CS Round', mode.read ? toText(sourceData.mw_cs_round) : textField('mw_cs_round', { type: 'number' })],
['MW CS Blunt', mode.read ? toText(sourceData.mw_cs_blunt) : textField('mw_cs_blunt', { type: 'number' })],
]}
/>,
<ArrayFrame
key="microwear"
title="Microwear"
array={[
['Microwear', toText(data.microwear)],
['MW scale min', toText(data.mw_scale_min)],
['MW scale max', toText(data.mw_scale_max)],
['MW value', toText(data.mw_value)],
[
'Microwear',
mode.read ? toText(sourceData.microwear) : dropdown('microwear', microwearOptions, 'Microwear'),
],
[
'MW scale min',
mode.read ? toText(sourceData.mw_scale_min) : textField('mw_scale_min', { type: 'number' }),
],
[
'MW scale max',
mode.read ? toText(sourceData.mw_scale_max) : textField('mw_scale_max', { type: 'number' }),
],
['MW value', mode.read ? toText(sourceData.mw_value) : textField('mw_value', { type: 'number' })],
]}
/>,
]}
@@ -8,11 +8,11 @@ jest.mock('@/components/DetailView/Context/DetailContext', () => ({
useDetailContext: jest.fn(),
}))
const capturedArrayFrameProps: Array<{ title: string; array: Array<Array<string>> }> = []
const capturedArrayFrameProps: Array<{ title: string; array: Array<Array<unknown>> }> = []
jest.mock('@/components/DetailView/common/tabLayoutHelpers', () => ({
HalfFrames: ({ children }: { children: ReactNode }) => <div>{children}</div>,
ArrayFrame: (props: { title: string; array: Array<Array<string>> }) => {
ArrayFrame: (props: { title: string; array: Array<Array<unknown>> }) => {
capturedArrayFrameProps.push(props)
return <div data-testid="array-frame" />
},
@@ -20,36 +20,43 @@ jest.mock('@/components/DetailView/common/tabLayoutHelpers', () => ({
const mockUseDetailContext = useDetailContext as jest.MockedFunction<typeof useDetailContext>
const baseOccurrence = {
lid: 1,
species_id: 2,
genus_name: 'Genus',
species_name: 'species',
id_status: 'certain',
orig_entry: 'orig',
source_name: 'src',
nis: 10,
pct: 50,
quad: 1,
mni: 2,
qua: 'a',
body_mass: 100,
}
describe('OccurrenceCoreTab', () => {
beforeEach(() => {
capturedArrayFrameProps.length = 0
mockUseDetailContext.mockReturnValue({
data: {
lid: 1,
species_id: 2,
genus_name: 'Genus',
species_name: 'species',
id_status: 'ok',
orig_entry: 'orig',
source_name: 'src',
nis: 10,
pct: 50,
quad: 1,
mni: 2,
qua: 'a',
body_mass: 100,
},
} as never)
})
it('uses Quantity label with mapped values and places source fields in Identification', () => {
it('renders read-mode values with quantity labels', () => {
mockUseDetailContext.mockReturnValue({
data: baseOccurrence,
editData: baseOccurrence,
mode: { read: true },
textField: jest.fn(),
dropdown: jest.fn(),
} as never)
render(<OccurrenceCoreTab />)
const [identification, counts, size] = capturedArrayFrameProps
expect(identification?.title).toBe('Identification')
expect(identification?.array.some(row => row[0] === 'Original entry')).toBe(true)
expect(identification?.array.some(row => row[0] === 'Source name')).toBe(true)
expect(identification?.array.some(row => row[0] === 'Original entry' && row[1] === 'orig')).toBe(true)
expect(identification?.array.some(row => row[0] === 'Source name' && row[1] === 'src')).toBe(true)
expect(counts?.title).toBe('Occurrence counts')
expect(counts?.array.some(row => row[0] === 'Quantity' && row[1] === 'Abundant')).toBe(true)
@@ -57,4 +64,27 @@ describe('OccurrenceCoreTab', () => {
expect(size?.title).toBe('Size')
expect(size?.array).toEqual([['Body mass', '100']])
})
it('renders editable controls in edit mode including ID status and Quantity dropdowns', () => {
const dropdown = jest.fn((_field: string) => 'dropdown-control')
const textField = jest.fn((_field: string) => 'text-control')
mockUseDetailContext.mockReturnValue({
data: baseOccurrence,
editData: baseOccurrence,
mode: { read: false },
textField,
dropdown,
} as never)
render(<OccurrenceCoreTab />)
const [identification, counts] = capturedArrayFrameProps
expect(dropdown).toHaveBeenCalledWith('id_status', expect.any(Array), 'ID status')
expect(dropdown).toHaveBeenCalledWith('qua', expect.any(Array), 'Quantity')
expect(identification?.array.some(row => row[0] === 'ID status' && row[1] === 'dropdown-control')).toBe(true)
expect(counts?.array.some(row => row[0] === 'Quantity' && row[1] === 'dropdown-control')).toBe(true)
expect(textField).toHaveBeenCalledWith('orig_entry', { type: 'text' })
})
})
@@ -0,0 +1,82 @@
import { beforeEach, describe, expect, it, jest } from '@jest/globals'
import { render } from '@testing-library/react'
import type { ReactNode } from 'react'
import { useDetailContext } from '@/components/DetailView/Context/DetailContext'
import { OccurrenceWearTab } from '../OccurrenceWearTab'
jest.mock('@/components/DetailView/Context/DetailContext', () => ({
useDetailContext: jest.fn(),
}))
const capturedArrayFrameProps: Array<{ title: string; array: Array<Array<unknown>> }> = []
jest.mock('@/components/DetailView/common/tabLayoutHelpers', () => ({
HalfFrames: ({ children }: { children: ReactNode }) => <div>{children}</div>,
ArrayFrame: (props: { title: string; array: Array<Array<unknown>> }) => {
capturedArrayFrameProps.push(props)
return <div data-testid="array-frame" />
},
}))
const mockUseDetailContext = useDetailContext as jest.MockedFunction<typeof useDetailContext>
const wearData = {
mesowear: 'bil',
mw_or_high: 1,
mw_or_low: 2,
mw_cs_sharp: 3,
mw_cs_round: 4,
mw_cs_blunt: 5,
microwear: 'pit_dom',
mw_scale_min: 1,
mw_scale_max: 5,
mw_value: 3,
}
describe('OccurrenceWearTab', () => {
beforeEach(() => {
capturedArrayFrameProps.length = 0
})
it('renders read-mode values', () => {
mockUseDetailContext.mockReturnValue({
data: wearData,
editData: wearData,
mode: { read: true },
textField: jest.fn(),
dropdown: jest.fn(),
} as never)
render(<OccurrenceWearTab />)
const [mesowearFrame, microwearFrame] = capturedArrayFrameProps
expect(mesowearFrame?.array.some(row => row[0] === 'Mesowear' && row[1] === 'bil')).toBe(true)
expect(microwearFrame?.array.some(row => row[0] === 'Microwear' && row[1] === 'pit_dom')).toBe(true)
})
it('renders editable dropdowns for mesowear and microwear in edit mode', () => {
const dropdown = jest.fn((_field: string) => 'dropdown-control')
const textField = jest.fn((_field: string) => 'text-control')
mockUseDetailContext.mockReturnValue({
data: wearData,
editData: wearData,
mode: { read: false },
textField,
dropdown,
} as never)
render(<OccurrenceWearTab />)
const [mesowearFrame, microwearFrame] = capturedArrayFrameProps
expect(dropdown).toHaveBeenCalledWith('mesowear', expect.any(Array), 'Mesowear')
expect(dropdown).toHaveBeenCalledWith('microwear', expect.any(Array), 'Microwear')
expect(mesowearFrame?.array.some(row => row[0] === 'Mesowear' && row[1] === 'dropdown-control')).toBe(true)
expect(microwearFrame?.array.some(row => row[0] === 'Microwear' && row[1] === 'dropdown-control')).toBe(true)
expect(textField).toHaveBeenCalledWith('mw_scale_min', { type: 'number' })
expect(textField).toHaveBeenCalledWith('mw_scale_max', { type: 'number' })
expect(textField).toHaveBeenCalledWith('mw_value', { type: 'number' })
})
})
@@ -8,6 +8,10 @@ jest.mock('@/hooks/useOccurrenceDetails', () => ({
useOccurrenceDetails: jest.fn(),
}))
jest.mock('@/hooks/notification', () => ({
useNotify: () => ({ notify: jest.fn(), setMessage: jest.fn() }),
}))
jest.mock('../Tabs/OccurrenceCoreTab', () => ({ OccurrenceCoreTab: () => <div>Core tab</div> }))
jest.mock('../Tabs/OccurrenceWearTab', () => ({ OccurrenceWearTab: () => <div>Wear tab</div> }))
jest.mock('../Tabs/OccurrenceIsotopeTab', () => ({ OccurrenceIsotopeTab: () => <div>Isotope tab</div> }))
@@ -0,0 +1,30 @@
import { emptyOption } from '@/components/DetailView/common/misc'
import { occurrenceDropdownValues } from '@/shared/validators/occurrence'
export const quantityOptions = [
emptyOption,
{ value: occurrenceDropdownValues.quantity[0], display: 'Abundant' },
{ value: occurrenceDropdownValues.quantity[1], display: 'Common' },
{ value: occurrenceDropdownValues.quantity[2], display: 'Rare' },
{ value: occurrenceDropdownValues.quantity[3], display: 'Very rare' },
]
export const idStatusOptions = [
emptyOption,
...occurrenceDropdownValues.idStatus.map(value => ({ value, display: value })),
]
export const mesowearOptions = [
emptyOption,
{ value: occurrenceDropdownValues.mesowear[0], display: 'Abrasion-dominated' },
{ value: occurrenceDropdownValues.mesowear[1], display: 'Mixed-dominated' },
{ value: occurrenceDropdownValues.mesowear[2], display: 'Attrition-dominated' },
{ value: occurrenceDropdownValues.mesowear[3], display: 'Unworn' },
]
export const microwearOptions = [
emptyOption,
{ value: occurrenceDropdownValues.microwear[0], display: 'Pits predominant' },
{ value: occurrenceDropdownValues.microwear[1], display: 'Pits and striae appear equally dominant' },
{ value: occurrenceDropdownValues.microwear[2], display: 'Striations predominant' },
]