Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,3 +54,10 @@ The new version is not deployed yet.
- Shared locality validation enforces combined pollen constraint `AP + NAP + OP <= 100` and surfaces the same message in form validation feedback.
- Backend locality write validation now evaluates pollen totals with full update context so partial updates cannot bypass the combined-value rule.
- API tests include invalid create/update pollen payload coverage for non-integer, out-of-range, and total-above-100 cases.

### Reviewer notes: Locality age method persistence

- In Locality edit mode, the Age tab now preserves method-specific values when switching Dating method between `Time unit`, `Absolute`, and `Composite`.
- Switching methods restores previously entered age values (e.g., `min_age`, `max_age`, basis/fraction selections) instead of clearing them.
- Locality age validation now evaluates required basis fields against the currently active dating method, while allowing preserved values from other methods to remain in draft state.
- See [documentation/CHANGELOG.md](documentation/CHANGELOG.md) for release-level tracking.
2 changes: 2 additions & 0 deletions documentation/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@
### Fixed
- Resolved Update log detail navigation so Return buttons honor the originating table using the new shared return-navigation helper.
- Restored Locality edit view synonym creation so saving a valid synonym immediately appears in the Synonyms table.
- Locality Age tab now preserves age-entry drafts per dating method (`time_unit`, `absolute`, `composite`) so switching methods no longer clears previously entered min/max age and basis values.
- Locality age validation now enforces required age-basis rules against the active dating method while tolerating preserved values from non-active methods.

### Added
- Species table Genus and Species filters now also match synonym names (syn_genus_name and syn_species_name) returned with each species record.
4 changes: 3 additions & 1 deletion frontend/src/components/DetailView/Context/DetailContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ export type DetailContextType<T> = {
mode: ModeType
setMode: (newMode: ModeOptions) => void
editData: EditDataType<T>
setEditData: (editData: EditDataType<T>) => void
setEditData: SetEditDataType<T>
textField: (field: keyof EditDataType<T>, options?: TextFieldOptions) => JSX.Element
bigTextField: (field: keyof EditDataType<T>) => JSX.Element
dropdown: (
Expand Down Expand Up @@ -87,6 +87,8 @@ export type DetailContextType<T> = {
resetEditData: () => void
}

export type SetEditDataType<T> = (editData: EditDataType<T>) => void

export const DetailContext = createContext<DetailContextType<unknown>>(null!)

export const makeEditData = <T,>(data: T): EditDataType<T> => ({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,45 @@ const Wrapper = ({ children }: { children: ReactNode }) => {
)
}

const FractionUpdateWrapper = () => {
const initialEditData = {
min_age: 10,
max_age: 20,
frac_min: '',
bfa_min: '',
} as unknown as EditDataType<LocalityDetailsType>

const [editData, setEditData] = useState<EditDataType<LocalityDetailsType>>(initialEditData)

const contextValue: DetailContextType<LocalityDetailsType> = {
data: initialEditData as unknown as LocalityDetailsType,
mode: modeOptionToMode.edit,
setMode: () => undefined,
editData,
setEditData,
isDirty: false,
resetEditData: () => setEditData(initialEditData),
textField: () => <></>,
bigTextField: () => <></>,
dropdown: () => <></>,
dropdownWithSearch: () => <></>,
radioSelection: () => <></>,
validator: () => ({ name: '', error: null }),
fieldsWithErrors: {},
setFieldsWithErrors: () => undefined,
}

return (
<DetailContext.Provider value={contextValue as DetailContextType<unknown>}>
<div data-testid="min-age">{editData.min_age}</div>
<button type="button" onClick={() => setEditData({ ...editData, frac_min: '1:2' })}>
set fraction 1:2
</button>
<BasisForAgeSelection targetField="bfa_min" fraction={editData.frac_min} selectorTable={<SelectorTable />} />
</DetailContext.Provider>
)
}

describe('BasisForAgeSelection', () => {
it('keeps minimum age populated when selecting a time unit without a fraction', async () => {
const user = userEvent.setup()
Expand All @@ -83,4 +122,23 @@ describe('BasisForAgeSelection', () => {
expect(updatedBasisField.value).toBe('abdounian')
})
})

it('recalculates minimum age when fraction changes after selecting a time unit', async () => {
const user = userEvent.setup()

render(<FractionUpdateWrapper />)

await user.click(screen.getByRole('textbox'))
await user.click(screen.getByRole('button', { name: /choose time unit/i }))

await waitFor(() => {
expect(screen.getByTestId('min-age').textContent).toContain('45')
})

await user.click(screen.getByRole('button', { name: /set fraction 1:2/i }))

await waitFor(() => {
expect(screen.getByTestId('min-age').textContent).toContain('42.5')
})
})
})
196 changes: 196 additions & 0 deletions frontend/src/components/Locality/Tabs/AgeTab.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,196 @@
import { describe, expect, it, jest } from '@jest/globals'
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { ReactNode, useState } from 'react'
import { AgeTab } from './AgeTab'
import { DetailContext, DetailContextType, modeOptionToMode } from '@/components/DetailView/Context/DetailContext'
import { EditDataType, LocalityDetailsType } from '@/shared/types'
import { DropdownOption } from '@/components/DetailView/common/editingComponents'
import { OptionalRadioSelectionProps, TextFieldOptions } from '@/components/DetailView/DetailView'
import '@testing-library/jest-dom'

jest.mock('@/redux/timeUnitReducer', () => ({
useGetTimeUnitDetailsQuery: () => ({ data: undefined, isFetching: false }),
}))

jest.mock('@/components/TimeUnit/TimeUnitTable', () => ({
TimeUnitTable: () => <div>TimeUnitTable</div>,
}))

const toDisplayValue = (option: DropdownOption | string): string => {
if (typeof option === 'string') return option
return option.display
}

const toRawValue = (option: DropdownOption | string): number | string | boolean => {
if (typeof option === 'string') return option
return option.value
}

const initialEditData = {
date_meth: 'time_unit',
min_age: 10,
max_age: 20,
bfa_min_abs: '',
bfa_max_abs: '',
bfa_min: 'tu-min-initial',
bfa_max: 'tu-max-initial',
frac_min: '1:2',
frac_max: '2:2',
chron: '',
age_comm: '',
} as unknown as EditDataType<LocalityDetailsType>

const toInputValue = (value: unknown): string => {
if (typeof value === 'string' || typeof value === 'number') return String(value)
return ''
}

const ContextWrapper = ({ children }: { children: ReactNode }) => {
const [editData, setEditData] = useState<EditDataType<LocalityDetailsType>>(initialEditData)

const setFieldValue = (field: keyof EditDataType<LocalityDetailsType>, value: string) => {
setEditData(prev => ({
...prev,
[field]: value === '' ? '' : Number.isNaN(Number(value)) ? value : Number(value),
}))
}

const contextValue: DetailContextType<LocalityDetailsType> = {
data: initialEditData as unknown as LocalityDetailsType,
mode: modeOptionToMode.edit,
setMode: () => undefined,
editData,
setEditData,
isDirty: false,
resetEditData: () => setEditData(initialEditData),
textField: (field: keyof EditDataType<LocalityDetailsType>, options?: TextFieldOptions) => (
<input
aria-label={String(field)}
value={toInputValue(editData[field])}
readOnly={options?.readonly}
onChange={event => setFieldValue(field, event.currentTarget.value)}
/>
),
bigTextField: (field: keyof EditDataType<LocalityDetailsType>) => (
<textarea
aria-label={String(field)}
value={toInputValue(editData[field])}
onChange={event => setFieldValue(field, event.currentTarget.value)}
/>
),
dropdown: (field: keyof EditDataType<LocalityDetailsType>) => (
<input
aria-label={String(field)}
value={toInputValue(editData[field])}
onChange={event => setFieldValue(field, event.currentTarget.value)}
/>
),
dropdownWithSearch: () => <></>,
radioSelection: (
field: keyof EditDataType<LocalityDetailsType>,
options: Array<DropdownOption | string>,
_name: string,
optionalRadioSelectionProps?: OptionalRadioSelectionProps
) => (
<>
{options.map(option => (
<button
key={String(toRawValue(option))}
type="button"
onClick={() => {
const value = toRawValue(option)
if (optionalRadioSelectionProps?.handleSetEditData) {
optionalRadioSelectionProps.handleSetEditData(value)
return
}
setEditData(prev => ({ ...prev, [field]: value }))
}}
>
{toDisplayValue(option)}
</button>
))}
</>
),
validator: () => ({ name: '', error: null }),
fieldsWithErrors: {},
setFieldsWithErrors: () => undefined,
}

return <DetailContext.Provider value={contextValue as DetailContextType<unknown>}>{children}</DetailContext.Provider>
}

describe('AgeTab', () => {
it('preserves per-method age drafts while switching dating method', async () => {
const user = userEvent.setup()

render(
<ContextWrapper>
<AgeTab />
</ContextWrapper>
)

await user.click(screen.getByRole('button', { name: 'Composite' }))

const minAgeInput = screen.getByLabelText('min_age')
const maxAgeInput = screen.getByLabelText('max_age')

await user.clear(minAgeInput)
await user.type(minAgeInput, '1.5')
await user.clear(maxAgeInput)
await user.type(maxAgeInput, '2.5')
const minAbsInput = screen.getByLabelText('bfa_min_abs')
const maxAbsInput = screen.getByLabelText('bfa_max_abs')

await user.clear(minAbsInput)
await user.type(minAbsInput, 'AAR')
await user.clear(maxAbsInput)
await user.type(maxAbsInput, 'C14')

await user.click(screen.getByRole('button', { name: 'Time unit' }))

expect(screen.getByLabelText<HTMLInputElement>('min_age').value).toBe('10')
expect(screen.getByLabelText<HTMLInputElement>('max_age').value).toBe('20')
expect(screen.getByLabelText<HTMLInputElement>('bfa_min').value).toBe('tu-min-initial')
expect(screen.getByLabelText<HTMLInputElement>('bfa_max').value).toBe('tu-max-initial')
expect(screen.getByLabelText<HTMLInputElement>('frac_min').value).toBe('1:2')
expect(screen.getByLabelText<HTMLInputElement>('frac_max').value).toBe('2:2')

await user.click(screen.getByRole('button', { name: 'Composite' }))

expect(screen.getByLabelText<HTMLInputElement>('min_age').value).toBe('1.5')
expect(screen.getByLabelText<HTMLInputElement>('max_age').value).toBe('2.5')
expect(screen.getByLabelText<HTMLInputElement>('bfa_min_abs').value).toBe('AAR')
expect(screen.getByLabelText<HTMLInputElement>('bfa_max_abs').value).toBe('C14')
})

it('restores time_unit values after a composite ↔ time_unit round-trip', async () => {
const user = userEvent.setup()

render(
<ContextWrapper>
<AgeTab />
</ContextWrapper>
)

await user.click(screen.getByRole('button', { name: 'Composite' }))

await user.clear(screen.getByLabelText('min_age'))
await user.type(screen.getByLabelText('min_age'), '3.1')
await user.clear(screen.getByLabelText('max_age'))
await user.type(screen.getByLabelText('max_age'), '3.9')

await user.click(screen.getByRole('button', { name: 'Time unit' }))
await user.click(screen.getByRole('button', { name: 'Composite' }))

expect(screen.getByLabelText<HTMLInputElement>('min_age').value).toBe('3.1')
expect(screen.getByLabelText<HTMLInputElement>('max_age').value).toBe('3.9')

await user.click(screen.getByRole('button', { name: 'Time unit' }))

expect(screen.getByLabelText<HTMLInputElement>('min_age').value).toBe('10')
expect(screen.getByLabelText<HTMLInputElement>('max_age').value).toBe('20')
expect(screen.getByLabelText<HTMLInputElement>('bfa_min').value).toBe('tu-min-initial')
expect(screen.getByLabelText<HTMLInputElement>('bfa_max').value).toBe('tu-max-initial')
})
})
Loading
Loading