mirror of
https://github.com/nowcommunity/nowdatabase.git
synced 2026-10-12 19:13:52 +03:00
Enable editable occurrence core and wear tab controls
This commit is contained in:
@@ -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' },
|
||||
]
|
||||
Reference in New Issue
Block a user