diff --git a/README.md b/README.md index 0f9b94a0..00cfc613 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/documentation/CHANGELOG.md b/documentation/CHANGELOG.md index eead4a6c..25910e96 100644 --- a/documentation/CHANGELOG.md +++ b/documentation/CHANGELOG.md @@ -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. diff --git a/frontend/src/components/DetailView/Context/DetailContext.tsx b/frontend/src/components/DetailView/Context/DetailContext.tsx index d085a0a4..a8b18cc1 100755 --- a/frontend/src/components/DetailView/Context/DetailContext.tsx +++ b/frontend/src/components/DetailView/Context/DetailContext.tsx @@ -58,7 +58,7 @@ export type DetailContextType = { mode: ModeType setMode: (newMode: ModeOptions) => void editData: EditDataType - setEditData: (editData: EditDataType) => void + setEditData: SetEditDataType textField: (field: keyof EditDataType, options?: TextFieldOptions) => JSX.Element bigTextField: (field: keyof EditDataType) => JSX.Element dropdown: ( @@ -87,6 +87,8 @@ export type DetailContextType = { resetEditData: () => void } +export type SetEditDataType = (editData: EditDataType) => void + export const DetailContext = createContext>(null!) export const makeEditData = (data: T): EditDataType => ({ diff --git a/frontend/src/components/DetailView/common/editingComponents.test.tsx b/frontend/src/components/DetailView/common/editingComponents.test.tsx index a3f78ab7..9285f6f0 100644 --- a/frontend/src/components/DetailView/common/editingComponents.test.tsx +++ b/frontend/src/components/DetailView/common/editingComponents.test.tsx @@ -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 + + const [editData, setEditData] = useState>(initialEditData) + + const contextValue: DetailContextType = { + 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 ( + }> +
{editData.min_age}
+ + } /> +
+ ) +} + describe('BasisForAgeSelection', () => { it('keeps minimum age populated when selecting a time unit without a fraction', async () => { const user = userEvent.setup() @@ -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() + + 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') + }) + }) }) diff --git a/frontend/src/components/Locality/Tabs/AgeTab.test.tsx b/frontend/src/components/Locality/Tabs/AgeTab.test.tsx new file mode 100644 index 00000000..149640fe --- /dev/null +++ b/frontend/src/components/Locality/Tabs/AgeTab.test.tsx @@ -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: () =>
TimeUnitTable
, +})) + +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 + +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>(initialEditData) + + const setFieldValue = (field: keyof EditDataType, value: string) => { + setEditData(prev => ({ + ...prev, + [field]: value === '' ? '' : Number.isNaN(Number(value)) ? value : Number(value), + })) + } + + const contextValue: DetailContextType = { + data: initialEditData as unknown as LocalityDetailsType, + mode: modeOptionToMode.edit, + setMode: () => undefined, + editData, + setEditData, + isDirty: false, + resetEditData: () => setEditData(initialEditData), + textField: (field: keyof EditDataType, options?: TextFieldOptions) => ( + setFieldValue(field, event.currentTarget.value)} + /> + ), + bigTextField: (field: keyof EditDataType) => ( +