From 6a82187fa0b04e67f5a0130d6f1d115e8e8d0082 Mon Sep 17 00:00:00 2001 From: juhanikat Date: Thu, 3 Sep 2026 12:51:53 +0300 Subject: [PATCH 01/23] add button in locality occurrence tab to create new occurrence, update occurrence creation --- .../Locality/Tabs/OccurrencesTab.tsx | 11 ++- .../Occurrence/OccurrenceDetails.tsx | 50 +++++++++--- .../Occurrence/Tabs/OccurrenceCoreTab.tsx | 72 ++++++++--------- .../Occurrence/Tabs/OccurrenceWearTab.tsx | 78 ++++--------------- frontend/src/components/Page.tsx | 3 +- .../components/Species/Tabs/TaxonomyTab.tsx | 5 +- .../components/Species/taxonomySuggestions.ts | 4 +- .../src/components/TableView/TableView.tsx | 2 +- frontend/src/hooks/useSyncTabSearch.ts | 1 + frontend/src/redux/api.ts | 13 ++-- frontend/src/router/index.tsx | 8 ++ 11 files changed, 118 insertions(+), 129 deletions(-) diff --git a/frontend/src/components/Locality/Tabs/OccurrencesTab.tsx b/frontend/src/components/Locality/Tabs/OccurrencesTab.tsx index 2f684c62e..8425779c3 100644 --- a/frontend/src/components/Locality/Tabs/OccurrencesTab.tsx +++ b/frontend/src/components/Locality/Tabs/OccurrencesTab.tsx @@ -3,7 +3,7 @@ import { EditableTable } from '@/components/DetailView/common/EditableTable' import { EditingModal } from '@/components/DetailView/common/EditingModal' import { Grouped } from '@/components/DetailView/common/tabLayoutHelpers' import { useDetailContext } from '@/components/DetailView/Context/DetailContext' -import { Box, TextField } from '@mui/material' +import { Box, Button, TextField } from '@mui/material' import { MRT_ColumnDef, MRT_Row, MRT_RowData, MRT_TableInstance } from 'material-react-table' import { useForm } from 'react-hook-form' import { calculateNormalizedMesowearScore } from '@/shared/utils/mesowear' @@ -248,6 +248,15 @@ export const OccurrencesTab = () => { return ( + {!mode.read && editingModal} , LocalityDetailsType> columns={columns} diff --git a/frontend/src/components/Occurrence/OccurrenceDetails.tsx b/frontend/src/components/Occurrence/OccurrenceDetails.tsx index 5f69914d7..fb30c75da 100644 --- a/frontend/src/components/Occurrence/OccurrenceDetails.tsx +++ b/frontend/src/components/Occurrence/OccurrenceDetails.tsx @@ -1,15 +1,15 @@ import { CircularProgress } from '@mui/material' -import { useParams } from 'react-router-dom' +import { useParams, useSearchParams } from 'react-router-dom' import { DetailView, TabType } from '@/components/DetailView/DetailView' import { UpdateTab } from '@/components/DetailView/common/UpdateTab' import { OccurrenceCoreTab } from './Tabs/OccurrenceCoreTab' import { OccurrenceWearTab } from './Tabs/OccurrenceWearTab' import { OccurrenceIsotopeTab } from './Tabs/OccurrenceIsotopeTab' -import { useOccurrenceDetails } from '@/hooks/useOccurrenceDetails' import { EditDataType, EditableOccurrenceData, OccurrenceDetailsType } from '@/shared/types' import { validateOccurrence } from '@/shared/validators/occurrence' import { getErrorMessage, useNotify } from '@/hooks/notification' import { ValidationObject } from '@/shared/validators/validator' +import { useEditOccurrenceMutation, useGetOccurrenceDetailsQuery } from '@/redux/api' const validateOccurrenceDetail = ( editData: EditDataType, @@ -67,20 +67,49 @@ const emptyOccurrence: OccurrenceDetailsType = { } export const OccurrenceDetails = () => { - const { lid, speciesId } = useParams() - const parsedLid = lid ? parseInt(lid, 10) : null - const parsedSpeciesId = speciesId ? parseInt(speciesId, 10) : null - const { occurrence, isLoading, isSaving, isError, saveOccurrence } = useOccurrenceDetails(parsedLid, parsedSpeciesId) + const { id, lid, speciesId } = useParams() + const [searchParams] = useSearchParams() + console.log(searchParams) + const isNew = id === 'new' + if (isNew) { + document.title = 'New locality' + } + const parsedLid = lid ? parseInt(lid, 10) : -1 + const parsedSpeciesId = speciesId ? parseInt(speciesId, 10) : -1 + const { + data: occurrenceData, + isLoading, + isError, + } = useGetOccurrenceDetailsQuery( + { lid: parsedLid, speciesId: parsedSpeciesId }, + { + skip: isNew, + } + ) const { notify } = useNotify() + const [editOccurrenceRequest, { isLoading: mutationLoading }] = useEditOccurrenceMutation() + + const lidFromSearchParams = searchParams.get('lid') + const locNameFromSearchParams = searchParams.get('loc_name') if (isError) return
Error loading occurrence data
- if (isLoading || isSaving || !occurrence) return + if (isLoading || (!occurrenceData && !isNew) || mutationLoading) return - document.title = `Occurrence - ${occurrence.lid}/${occurrence.species_id}` + const initialOccurrence = emptyOccurrence + + if (isNew) { + initialOccurrence.lid = parseInt(lidFromSearchParams!, 10) + initialOccurrence.loc_name = locNameFromSearchParams! + } + + if (occurrenceData) { + document.title = `Occurrence - ${occurrenceData.lid}/${occurrenceData.species_id}` + } const onWrite = async (editData: EditDataType) => { try { - await saveOccurrence(editData) + console.log(editData) + await editOccurrenceRequest(editData).unwrap() notify('Occurrence entry finalized successfully.') } catch (error) { notify(getErrorMessage(error, 'Could not finalize occurrence entry.'), 'error') @@ -101,7 +130,8 @@ export const OccurrenceDetails = () => { return ( tabs={tabs} - data={occurrence ?? emptyOccurrence} + data={occurrenceData ?? initialOccurrence} + isNew={isNew} validator={validateOccurrenceDetail} onWrite={onWrite} hasStagingMode diff --git a/frontend/src/components/Occurrence/Tabs/OccurrenceCoreTab.tsx b/frontend/src/components/Occurrence/Tabs/OccurrenceCoreTab.tsx index 90209194a..81f24b7a2 100644 --- a/frontend/src/components/Occurrence/Tabs/OccurrenceCoreTab.tsx +++ b/frontend/src/components/Occurrence/Tabs/OccurrenceCoreTab.tsx @@ -2,14 +2,36 @@ import { useDetailContext } from '@/components/DetailView/Context/DetailContext' import { ArrayFrame, HalfFrames } from '@/components/DetailView/common/tabLayoutHelpers' import { OccurrenceDetailsType } from '@/shared/types' import { Link } from 'react-router-dom' -import { formatIdStatus, formatQuantity, idStatusOptions, quantityOptions } from '../constants' +import { idStatusOptions, quantityOptions } from '../constants' +import { useGetAllSpeciesQuery } from '@/redux/speciesReducer' +import { buildTaxonomySuggestionOptions, TaxonomySuggestionFieldName } from '@/components/Species/taxonomySuggestions' +import { useMemo } from 'react' +import { TaxonomySuggestionField } from '@/components/Species/TaxonomySuggestionField' const toText = (value: string | number | null | undefined) => value === null || value === undefined || value === '' ? '-' : String(value) export const OccurrenceCoreTab = () => { + const { data: speciesQueryData, isError } = useGetAllSpeciesQuery() const { data, editData, mode, textField, dropdown } = useDetailContext() - const sourceData = mode.read ? data : editData + + console.log(editData) + const taxonomySuggestionOptions = useMemo( + () => ({ + subclass_or_superorder_name: buildTaxonomySuggestionOptions(speciesQueryData, 'subclass_or_superorder_name'), + order_name: buildTaxonomySuggestionOptions(speciesQueryData, 'order_name'), + suborder_or_superfamily_name: buildTaxonomySuggestionOptions(speciesQueryData, 'suborder_or_superfamily_name'), + family_name: buildTaxonomySuggestionOptions(speciesQueryData, 'family_name'), + subfamily_name: buildTaxonomySuggestionOptions(speciesQueryData, 'subfamily_name'), + genus_name: buildTaxonomySuggestionOptions(speciesQueryData, 'genus_name'), + species_name: buildTaxonomySuggestionOptions(speciesQueryData, 'species_name'), + }), + [speciesQueryData] + ) + + const taxonomySuggestionField = (field: TaxonomySuggestionFieldName) => ( + + ) return ( <> @@ -25,51 +47,29 @@ export const OccurrenceCoreTab = () => { {toText(data.loc_name)} , ], - ['Family', toText(data.family_name)], - ['Genus', toText(data.genus_name)], - ['Species', toText(data.species_name)], - [ - 'ID status', - mode.read ? formatIdStatus(sourceData.id_status) : dropdown('id_status', idStatusOptions, 'ID status'), - ], - [ - 'Additional Information', - mode.read ? toText(sourceData.orig_entry) : textField('orig_entry', { type: 'text' }), - ], - ['Source name', mode.read ? toText(sourceData.source_name) : textField('source_name', { type: 'text' })], + ['Family', taxonomySuggestionField('family_name')], + ['Genus', taxonomySuggestionField('genus_name')], + ['Species', taxonomySuggestionField('species_name')], + ['ID status', dropdown('id_status', idStatusOptions, 'ID status')], + ['Additional Information', textField('orig_entry', { type: 'text' })], + ['Source name', textField('source_name', { type: 'text' })], ]} />, , ]} diff --git a/frontend/src/components/Occurrence/Tabs/OccurrenceWearTab.tsx b/frontend/src/components/Occurrence/Tabs/OccurrenceWearTab.tsx index 3a3780ca8..64cb25d71 100644 --- a/frontend/src/components/Occurrence/Tabs/OccurrenceWearTab.tsx +++ b/frontend/src/components/Occurrence/Tabs/OccurrenceWearTab.tsx @@ -1,16 +1,7 @@ import { useDetailContext } from '@/components/DetailView/Context/DetailContext' import { ArrayFrame, HalfFrames } from '@/components/DetailView/common/tabLayoutHelpers' import { OccurrenceDetailsType } from '@/shared/types' -import { - calculateNormalizedMesowearScore, - formatMesowear, - formatMicrowear, - mesowearOptions, - microwearOptions, -} from '../constants' - -const toText = (value: string | number | null | undefined) => - value === null || value === undefined || value === '' ? '-' : String(value) +import { calculateNormalizedMesowearScore, mesowearOptions, microwearOptions } from '../constants' export const OccurrenceWearTab = () => { const { data, editData, mode, textField, dropdown } = useDetailContext() @@ -22,6 +13,8 @@ export const OccurrenceWearTab = () => { ) const normalizedScoreText = normalizedScore === null ? '-' : normalizedScore.toFixed(2) + console.log(editData) + return ( {[ @@ -29,40 +22,12 @@ export const OccurrenceWearTab = () => { key="mesowear" title="Mesowear" array={[ - [ - 'Mesowear', - mode.read ? formatMesowear(sourceData.mesowear) : dropdown('mesowear', mesowearOptions, 'Mesowear'), - ], - [ - 'MW OR High', - mode.read - ? toText(sourceData.mw_or_high) - : textField('mw_or_high', { type: 'number', integerOnly: true, min: 0, max: 100 }), - ], - [ - 'MW OR Low', - mode.read - ? toText(sourceData.mw_or_low) - : textField('mw_or_low', { type: 'number', integerOnly: true, min: 0, max: 100 }), - ], - [ - 'MW CS Sharp', - mode.read - ? toText(sourceData.mw_cs_sharp) - : textField('mw_cs_sharp', { type: 'number', integerOnly: true, min: 0, max: 100 }), - ], - [ - 'MW CS Round', - mode.read - ? toText(sourceData.mw_cs_round) - : textField('mw_cs_round', { type: 'number', integerOnly: true, min: 0, max: 100 }), - ], - [ - 'MW CS Blunt', - mode.read - ? toText(sourceData.mw_cs_blunt) - : textField('mw_cs_blunt', { type: 'number', integerOnly: true, min: 0, max: 100 }), - ], + ['Mesowear', dropdown('mesowear', mesowearOptions, 'Mesowear')], + ['MW OR High', textField('mw_or_high', { type: 'number', integerOnly: true, min: 0, max: 100 })], + ['MW OR Low', textField('mw_or_low', { type: 'number', integerOnly: true, min: 0, max: 100 })], + ['MW CS Sharp', textField('mw_cs_sharp', { type: 'number', integerOnly: true, min: 0, max: 100 })], + ['MW CS Round', textField('mw_cs_round', { type: 'number', integerOnly: true, min: 0, max: 100 })], + ['MW CS Blunt', textField('mw_cs_blunt', { type: 'number', integerOnly: true, min: 0, max: 100 })], ]} />,
@@ -70,33 +35,16 @@ export const OccurrenceWearTab = () => { key="mesowear-score" title="Mesowear score" array={[ - [ - 'MW scale min', - mode.read - ? toText(sourceData.mw_scale_min) - : textField('mw_scale_min', { type: 'number', integerOnly: true, min: 0 }), - ], - [ - 'MW scale max', - mode.read - ? toText(sourceData.mw_scale_max) - : textField('mw_scale_max', { type: 'number', integerOnly: true, min: 0 }), - ], - ['MW value', mode.read ? toText(sourceData.mw_value) : textField('mw_value', { type: 'number' })], + ['MW scale min', textField('mw_scale_min', { type: 'number', integerOnly: true, min: 0 })], + ['MW scale max', textField('mw_scale_max', { type: 'number', integerOnly: true, min: 0 })], + ['MW value', textField('mw_value', { type: 'number' })], ['Normalized Score', normalizedScoreText], ]} />
, ]} diff --git a/frontend/src/components/Page.tsx b/frontend/src/components/Page.tsx index 31c76c742..b013e4794 100755 --- a/frontend/src/components/Page.tsx +++ b/frontend/src/components/Page.tsx @@ -109,8 +109,7 @@ export const Page = >({ getEditRights: (user: UserState, id: string | number) => EditRights }) => { const params = useParams() - const { id, lid } = params - const speciesId = params.speciesId + const { id, lid, speciesId } = params const hasDetailParams = Boolean(id || (lid && speciesId)) const editId = id ?? lid ?? '' const user = useUser() diff --git a/frontend/src/components/Species/Tabs/TaxonomyTab.tsx b/frontend/src/components/Species/Tabs/TaxonomyTab.tsx index 93f339294..0fe4248da 100755 --- a/frontend/src/components/Species/Tabs/TaxonomyTab.tsx +++ b/frontend/src/components/Species/Tabs/TaxonomyTab.tsx @@ -10,10 +10,7 @@ import { SynonymsModal } from '../SynonymsModal' import { useMemo, useState } from 'react' import { taxonStatusOptions } from '@/shared/taxonStatusOptions' import { TaxonomySuggestionField } from '../TaxonomySuggestionField' -import { - buildTaxonomySuggestionOptions, - TaxonomySuggestionField as TaxonomySuggestionFieldName, -} from '../taxonomySuggestions' +import { buildTaxonomySuggestionOptions, TaxonomySuggestionFieldName } from '../taxonomySuggestions' export const TaxonomyTab = () => { const { textField, dropdown, bigTextField, editData, setEditData, mode } = useDetailContext() diff --git a/frontend/src/components/Species/taxonomySuggestions.ts b/frontend/src/components/Species/taxonomySuggestions.ts index 30d85ac01..07e9a530f 100644 --- a/frontend/src/components/Species/taxonomySuggestions.ts +++ b/frontend/src/components/Species/taxonomySuggestions.ts @@ -1,6 +1,6 @@ import type { Species } from '@/shared/types' -export type TaxonomySuggestionField = +export type TaxonomySuggestionFieldName = | 'subclass_or_superorder_name' | 'order_name' | 'suborder_or_superfamily_name' @@ -11,7 +11,7 @@ export type TaxonomySuggestionField = export const buildTaxonomySuggestionOptions = ( speciesData: Species[] | undefined, - field: TaxonomySuggestionField + field: TaxonomySuggestionFieldName ): string[] => { if (!speciesData) return [] diff --git a/frontend/src/components/TableView/TableView.tsx b/frontend/src/components/TableView/TableView.tsx index f724d5baa..e874e9fa2 100755 --- a/frontend/src/components/TableView/TableView.tsx +++ b/frontend/src/components/TableView/TableView.tsx @@ -799,7 +799,7 @@ export const TableView = ({ tableName={title} kmlExport={kmlExport} svgExport={svgExport} - showNewButton={editRights.new && !selectorFn && !isCrossSearchTable} + showNewButton={editRights.new && !selectorFn} isCrossSearchTable={isCrossSearchTable} selectorFn={selectorFn} hideLeftButtons={false} diff --git a/frontend/src/hooks/useSyncTabSearch.ts b/frontend/src/hooks/useSyncTabSearch.ts index d2e350970..2b43f507b 100644 --- a/frontend/src/hooks/useSyncTabSearch.ts +++ b/frontend/src/hooks/useSyncTabSearch.ts @@ -18,6 +18,7 @@ export const useSyncTabSearch = (tab: number) => { }, [location.key, location.state]) useEffect(() => { + return const params = new URLSearchParams(location.search) if (params.get('tab') === String(tab)) return diff --git a/frontend/src/redux/api.ts b/frontend/src/redux/api.ts index e8c1355ff..8ee79d5e2 100755 --- a/frontend/src/redux/api.ts +++ b/frontend/src/redux/api.ts @@ -8,7 +8,7 @@ import { } from '@reduxjs/toolkit/query/react' import { BACKEND_URL } from '../util/config' import type { RootState } from './store' -import { EditableOccurrenceData, OccurrenceDetailsType } from '@/shared/types' +import { EditDataType, OccurrenceDetailsType } from '@/shared/types' type RefreshTokenResult = { data?: { token?: string } @@ -116,16 +116,13 @@ const occurrenceApi = api.injectEndpoints({ }), providesTags: result => (result ? [{ type: 'occurrence', id: `${result.lid}-${result.species_id}` }] : []), }), - editOccurrence: builder.mutation< - OccurrenceDetailsType, - { lid: number; speciesId: number; occurrence: EditableOccurrenceData } - >({ - query: ({ lid, speciesId, occurrence }) => ({ - url: `/occurrence/${lid}/${speciesId}`, + editOccurrence: builder.mutation>({ + query: occurrence => ({ + url: `/occurrence/${occurrence.lid}/${occurrence.species_id}`, method: 'PUT', body: { occurrence }, }), - invalidatesTags: (_result, _error, { lid, speciesId }) => [{ type: 'occurrence', id: `${lid}-${speciesId}` }], + invalidatesTags: (_result, _error, { lid, species_id }) => [{ type: 'occurrence', id: `${lid}-${species_id}` }], }), }), }) diff --git a/frontend/src/router/index.tsx b/frontend/src/router/index.tsx index 799fb1a20..7e3fbd6b3 100644 --- a/frontend/src/router/index.tsx +++ b/frontend/src/router/index.tsx @@ -36,6 +36,14 @@ const router = createBrowserRouter([ return { Component: OccurrencesPage } }, }, + { + path: 'occurrence/:id', + // Matches occurrence/new + lazy: async () => { + const { OccurrencesPage } = await import('../pages/OccurrencesPage') + return { Component: OccurrencesPage } + }, + }, { path: 'occurrence', lazy: async () => { From 8634ef12e3f0aec2817db16b7030ed205543fd7c Mon Sep 17 00:00:00 2001 From: juhanikat Date: Mon, 7 Sep 2026 14:30:04 +0300 Subject: [PATCH 02/23] Occurrence details page now edits or creates occurrences through the locality route in backend, also add button to select species for occurrence --- backend/src/services/write/locality.ts | 5 + .../Locality/Tabs/OccurrencesTab.tsx | 21 ++-- .../Occurrence/OccurrenceDetails.tsx | 101 ++++++++++++++++-- .../Occurrence/Tabs/OccurrenceCoreTab.tsx | 94 +++++++++++----- frontend/src/redux/localityReducer.ts | 6 +- 5 files changed, 177 insertions(+), 50 deletions(-) diff --git a/backend/src/services/write/locality.ts b/backend/src/services/write/locality.ts index 815bc07a1..042b2632f 100644 --- a/backend/src/services/write/locality.ts +++ b/backend/src/services/write/locality.ts @@ -82,6 +82,11 @@ export const writeLocality = async ( localitySpecies.species_id = species_id as number } + for (const localitySpecies of locality.now_ls) { + if (localitySpecies.rowState === 'new' || localitySpecies.rowState === 'removed') continue + await writeHandler.updateObject('now_ls', localitySpecies, ['lid', 'species_id']) + } + await writeHandler.applyListChanges('now_ls', locality.now_ls, ['lid', 'species_id']) await writeHandler.applyListChanges('now_mus', locality.now_mus, ['lid', 'museum']) await writeHandler.applyListChanges('now_ss', locality.now_ss, ['lid', 'sed_struct']) diff --git a/frontend/src/components/Locality/Tabs/OccurrencesTab.tsx b/frontend/src/components/Locality/Tabs/OccurrencesTab.tsx index 8425779c3..bd074b782 100644 --- a/frontend/src/components/Locality/Tabs/OccurrencesTab.tsx +++ b/frontend/src/components/Locality/Tabs/OccurrencesTab.tsx @@ -248,16 +248,17 @@ export const OccurrencesTab = () => { return ( - - {!mode.read && editingModal} + {!mode.read && ( + + )} , LocalityDetailsType> columns={columns} field="now_ls" diff --git a/frontend/src/components/Occurrence/OccurrenceDetails.tsx b/frontend/src/components/Occurrence/OccurrenceDetails.tsx index fb30c75da..0bfe258c0 100644 --- a/frontend/src/components/Occurrence/OccurrenceDetails.tsx +++ b/frontend/src/components/Occurrence/OccurrenceDetails.tsx @@ -1,15 +1,22 @@ import { CircularProgress } from '@mui/material' -import { useParams, useSearchParams } from 'react-router-dom' +import { useNavigate, useParams, useSearchParams } from 'react-router-dom' import { DetailView, TabType } from '@/components/DetailView/DetailView' import { UpdateTab } from '@/components/DetailView/common/UpdateTab' import { OccurrenceCoreTab } from './Tabs/OccurrenceCoreTab' import { OccurrenceWearTab } from './Tabs/OccurrenceWearTab' import { OccurrenceIsotopeTab } from './Tabs/OccurrenceIsotopeTab' -import { EditDataType, EditableOccurrenceData, OccurrenceDetailsType } from '@/shared/types' +import { + EditDataType, + EditableOccurrenceData, + EditMetaData, + LocalityDetailsType, + OccurrenceDetailsType, +} from '@/shared/types' import { validateOccurrence } from '@/shared/validators/occurrence' import { getErrorMessage, useNotify } from '@/hooks/notification' import { ValidationObject } from '@/shared/validators/validator' -import { useEditOccurrenceMutation, useGetOccurrenceDetailsQuery } from '@/redux/api' +import { useGetOccurrenceDetailsQuery } from '@/redux/api' +import { useEditLocalityMutation, useGetLocalityDetailsQuery } from '@/redux/localityReducer' const validateOccurrenceDetail = ( editData: EditDataType, @@ -66,10 +73,41 @@ const emptyOccurrence: OccurrenceDetailsType = { now_oau: [], } +const occurrenceFields: Array = [ + '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 const OccurrenceDetails = () => { const { id, lid, speciesId } = useParams() const [searchParams] = useSearchParams() - console.log(searchParams) const isNew = id === 'new' if (isNew) { document.title = 'New locality' @@ -86,11 +124,15 @@ export const OccurrenceDetails = () => { skip: isNew, } ) + const { notify } = useNotify() - const [editOccurrenceRequest, { isLoading: mutationLoading }] = useEditOccurrenceMutation() + const navigate = useNavigate() + const [editLocalityRequest, { isLoading: mutationLoading }] = useEditLocalityMutation() const lidFromSearchParams = searchParams.get('lid') const locNameFromSearchParams = searchParams.get('loc_name') + const localityId = lidFromSearchParams ?? lid ?? '' + const { data: localityData } = useGetLocalityDetailsQuery(localityId) if (isError) return
Error loading occurrence data
if (isLoading || (!occurrenceData && !isNew) || mutationLoading) return @@ -98,19 +140,58 @@ export const OccurrenceDetails = () => { const initialOccurrence = emptyOccurrence if (isNew) { - initialOccurrence.lid = parseInt(lidFromSearchParams!, 10) - initialOccurrence.loc_name = locNameFromSearchParams! + initialOccurrence.lid = parseInt(localityId, 10) + initialOccurrence.loc_name = locNameFromSearchParams ?? '' } if (occurrenceData) { document.title = `Occurrence - ${occurrenceData.lid}/${occurrenceData.species_id}` } - const onWrite = async (editData: EditDataType) => { + const onWrite = async (editData: EditDataType & EditMetaData) => { try { - console.log(editData) - await editOccurrenceRequest(editData).unwrap() + if (!localityData) throw new Error('Could not load the linked locality.') + + const occurrenceSpeciesId = isNew ? editData.species_id : parsedSpeciesId + const existingOccurrence = localityData.now_ls.find(row => row.species_id === occurrenceSpeciesId) + const occurrenceData = occurrenceFields.reduce>((data, field) => { + if (field in editData) data[field] = editData[field] + return data + }, {}) + const occurrence = { + ...(existingOccurrence ?? {}), + lid: localityData.lid, + species_id: occurrenceSpeciesId ?? existingOccurrence?.species_id, + ...occurrenceData, + ...(isNew + ? { + rowState: 'new' as const, + com_species: { + com_taxa_synonym: [], + now_sau: [], + species_id: editData.species_id, + family_name: editData.family_name, + genus_name: editData.genus_name, + species_name: editData.species_name, + unique_identifier: editData.unique_identifier, + }, + } + : {}), + } + + const nowLs = ( + isNew + ? [...localityData.now_ls, occurrence] + : localityData.now_ls.map(row => (row.species_id === occurrenceSpeciesId ? occurrence : row)) + ) as EditDataType['now_ls'] + await editLocalityRequest({ + ...localityData, + now_ls: nowLs, + comment: editData.comment, + references: editData.references ?? [], + }).unwrap() notify('Occurrence entry finalized successfully.') + setTimeout(() => navigate(`/occurrence/${editData.lid}/${editData.species_id}`), 15) } catch (error) { notify(getErrorMessage(error, 'Could not finalize occurrence entry.'), 'error') throw error diff --git a/frontend/src/components/Occurrence/Tabs/OccurrenceCoreTab.tsx b/frontend/src/components/Occurrence/Tabs/OccurrenceCoreTab.tsx index 81f24b7a2..2d8da59c3 100644 --- a/frontend/src/components/Occurrence/Tabs/OccurrenceCoreTab.tsx +++ b/frontend/src/components/Occurrence/Tabs/OccurrenceCoreTab.tsx @@ -1,37 +1,51 @@ import { useDetailContext } from '@/components/DetailView/Context/DetailContext' import { ArrayFrame, HalfFrames } from '@/components/DetailView/common/tabLayoutHelpers' -import { OccurrenceDetailsType } from '@/shared/types' +import { EditDataType, LocalityDetailsType, OccurrenceDetailsType, Species, SpeciesDetailsType } from '@/shared/types' import { Link } from 'react-router-dom' import { idStatusOptions, quantityOptions } from '../constants' import { useGetAllSpeciesQuery } from '@/redux/speciesReducer' -import { buildTaxonomySuggestionOptions, TaxonomySuggestionFieldName } from '@/components/Species/taxonomySuggestions' -import { useMemo } from 'react' -import { TaxonomySuggestionField } from '@/components/Species/TaxonomySuggestionField' +import { SelectingTable } from '@/components/DetailView/common/SelectingTable' +import { MRT_ColumnDef } from 'material-react-table' const toText = (value: string | number | null | undefined) => value === null || value === undefined || value === '' ? '-' : String(value) - +const speciesColumns: MRT_ColumnDef[] = [ + { + accessorKey: 'order_name', + header: 'Order', + }, + { + accessorKey: 'family_name', + header: 'Family', + }, + { + accessorKey: 'genus_name', + header: 'Genus', + }, + { + accessorKey: 'species_name', + header: 'Species', + }, + { + accessorKey: 'subclass_or_superorder_name', + header: 'Subclass or Superorder', + }, + { + accessorKey: 'suborder_or_superfamily_name', + header: 'Suborder or Superfamily', + }, + { + accessorKey: 'unique_identifier', + header: 'Unique Identifier', + }, + { + accessorKey: 'taxonomic_status', + header: 'Taxon status', + }, +] export const OccurrenceCoreTab = () => { const { data: speciesQueryData, isError } = useGetAllSpeciesQuery() - const { data, editData, mode, textField, dropdown } = useDetailContext() - - console.log(editData) - const taxonomySuggestionOptions = useMemo( - () => ({ - subclass_or_superorder_name: buildTaxonomySuggestionOptions(speciesQueryData, 'subclass_or_superorder_name'), - order_name: buildTaxonomySuggestionOptions(speciesQueryData, 'order_name'), - suborder_or_superfamily_name: buildTaxonomySuggestionOptions(speciesQueryData, 'suborder_or_superfamily_name'), - family_name: buildTaxonomySuggestionOptions(speciesQueryData, 'family_name'), - subfamily_name: buildTaxonomySuggestionOptions(speciesQueryData, 'subfamily_name'), - genus_name: buildTaxonomySuggestionOptions(speciesQueryData, 'genus_name'), - species_name: buildTaxonomySuggestionOptions(speciesQueryData, 'species_name'), - }), - [speciesQueryData] - ) - - const taxonomySuggestionField = (field: TaxonomySuggestionFieldName) => ( - - ) + const { data, editData, setEditData, mode, textField, dropdown } = useDetailContext() return ( <> @@ -47,9 +61,35 @@ export const OccurrenceCoreTab = () => { {toText(data.loc_name)} , ], - ['Family', taxonomySuggestionField('family_name')], - ['Genus', taxonomySuggestionField('genus_name')], - ['Species', taxonomySuggestionField('species_name')], + !mode.read + ? [ + '', + { + setEditData({ + ...editData, + family_name: newSpecies.family_name, + genus_name: newSpecies.genus_name!, + species_name: newSpecies.species_name!, + species_id: newSpecies.species_id, + unique_identifier: newSpecies.unique_identifier, + }) + }} + />, + ] + : [], + ['Family', editData.family_name], + ['Genus', editData.genus_name], + ['Species', editData.species_name], ['ID status', dropdown('id_status', idStatusOptions, 'ID status')], ['Additional Information', textField('orig_entry', { type: 'text' })], ['Source name', textField('source_name', { type: 'text' })], diff --git a/frontend/src/redux/localityReducer.ts b/frontend/src/redux/localityReducer.ts index dcf2e4f76..f02376e06 100755 --- a/frontend/src/redux/localityReducer.ts +++ b/frontend/src/redux/localityReducer.ts @@ -1,6 +1,6 @@ import { api } from './api' import { addLocality } from '@/redux/userReducer' -import { EditDataType, Locality, LocalityDetailsType } from '@/shared/types' +import { EditDataType, EditMetaData, Locality, LocalityDetailsType } from '@/shared/types' const sanitizeLocalityProjects = (locality?: EditDataType) => { if (!locality || !locality.now_plr) return [] as LocalityDetailsType['now_plr'] @@ -23,14 +23,14 @@ const localitiesApi = api.injectEndpoints({ }), providesTags: result => (result ? [{ type: 'locality', id: result.lid }] : []), }), - editLocality: builder.mutation<{ id: number }, EditDataType>({ + editLocality: builder.mutation<{ id: number }, EditDataType & EditMetaData>({ query: locality => ({ url: `/locality`, method: 'PUT', body: { locality }, }), invalidatesTags: (result, _error, { lid }) => - result ? [{ type: 'locality', id: lid }, 'localities', 'specieslist'] : [], + result ? [{ type: 'locality', id: lid }, 'localities', 'specieslist', 'occurrence'] : [], async onQueryStarted(locality, { dispatch, queryFulfilled }) { if (!locality.lid) { try { From c0eb7adf78637dd4f186c7d87ea36bd12e4b1cc9 Mon Sep 17 00:00:00 2001 From: juhanikat Date: Mon, 7 Sep 2026 14:35:41 +0300 Subject: [PATCH 03/23] fix lint issues and slight changes in OccurrenceDetails --- frontend/src/components/Occurrence/OccurrenceDetails.tsx | 7 ++++--- .../src/components/Occurrence/Tabs/OccurrenceWearTab.tsx | 2 -- .../src/components/Species/TaxonomySuggestionField.tsx | 2 +- 3 files changed, 5 insertions(+), 6 deletions(-) diff --git a/frontend/src/components/Occurrence/OccurrenceDetails.tsx b/frontend/src/components/Occurrence/OccurrenceDetails.tsx index 0bfe258c0..2e5acb854 100644 --- a/frontend/src/components/Occurrence/OccurrenceDetails.tsx +++ b/frontend/src/components/Occurrence/OccurrenceDetails.tsx @@ -109,9 +109,7 @@ export const OccurrenceDetails = () => { const { id, lid, speciesId } = useParams() const [searchParams] = useSearchParams() const isNew = id === 'new' - if (isNew) { - document.title = 'New locality' - } + const parsedLid = lid ? parseInt(lid, 10) : -1 const parsedSpeciesId = speciesId ? parseInt(speciesId, 10) : -1 const { @@ -129,8 +127,10 @@ export const OccurrenceDetails = () => { const navigate = useNavigate() const [editLocalityRequest, { isLoading: mutationLoading }] = useEditLocalityMutation() + // these two should exist if the occurrence is created through a locality's Occurrences tab const lidFromSearchParams = searchParams.get('lid') const locNameFromSearchParams = searchParams.get('loc_name') + const localityId = lidFromSearchParams ?? lid ?? '' const { data: localityData } = useGetLocalityDetailsQuery(localityId) @@ -142,6 +142,7 @@ export const OccurrenceDetails = () => { if (isNew) { initialOccurrence.lid = parseInt(localityId, 10) initialOccurrence.loc_name = locNameFromSearchParams ?? '' + document.title = `New Occurrence` } if (occurrenceData) { diff --git a/frontend/src/components/Occurrence/Tabs/OccurrenceWearTab.tsx b/frontend/src/components/Occurrence/Tabs/OccurrenceWearTab.tsx index 64cb25d71..cffbfe853 100644 --- a/frontend/src/components/Occurrence/Tabs/OccurrenceWearTab.tsx +++ b/frontend/src/components/Occurrence/Tabs/OccurrenceWearTab.tsx @@ -13,8 +13,6 @@ export const OccurrenceWearTab = () => { ) const normalizedScoreText = normalizedScore === null ? '-' : normalizedScore.toFixed(2) - console.log(editData) - return ( {[ diff --git a/frontend/src/components/Species/TaxonomySuggestionField.tsx b/frontend/src/components/Species/TaxonomySuggestionField.tsx index e97dd7644..de2850bb5 100644 --- a/frontend/src/components/Species/TaxonomySuggestionField.tsx +++ b/frontend/src/components/Species/TaxonomySuggestionField.tsx @@ -5,7 +5,7 @@ import { useDetailContext } from '@/components/DetailView/Context/DetailContext' import { checkFieldErrors } from '@/components/DetailView/common/checkFieldErrors' import { DataValue } from '@/components/DetailView/common/tabLayoutHelpers' import type { EditDataType, SpeciesDetailsType } from '@/shared/types' -import type { TaxonomySuggestionField as TaxonomySuggestionFieldName } from './taxonomySuggestions' +import type { TaxonomySuggestionFieldName } from './taxonomySuggestions' const FIELD_WIDTH = '14em' From 7d8e345bc44b46c7c4f1e798cb3eeb7ace5e3c18 Mon Sep 17 00:00:00 2001 From: juhanikat Date: Mon, 7 Sep 2026 16:21:37 +0300 Subject: [PATCH 04/23] add refresh button to Locality -> Occurrence tab --- backend/src/routes/locality.ts | 7 +++ .../Locality/Tabs/OccurrencesTab.tsx | 46 +++++++++++-------- frontend/src/redux/localityReducer.ts | 6 ++- 3 files changed, 39 insertions(+), 20 deletions(-) diff --git a/backend/src/routes/locality.ts b/backend/src/routes/locality.ts index a1f77da9e..bcd4a5494 100644 --- a/backend/src/routes/locality.ts +++ b/backend/src/routes/locality.ts @@ -42,6 +42,13 @@ router.post('/export/dwc-archive', requireOneOf([Role.Admin]), async (req, res) return sendDwcArchive(ids, res) }) +router.get('/:id/occurrences', async (req, res) => { + const id = parseInt(req.params.id) + const locality = await getLocalityDetails(id, req.user) + if (!locality) return res.status(404).send() + return res.status(200).send(fixBigInt(locality.now_ls)) +}) + router.get('/:id', async (req, res) => { const id = parseInt(req.params.id) const locality = await getLocalityDetails(id, req.user) diff --git a/frontend/src/components/Locality/Tabs/OccurrencesTab.tsx b/frontend/src/components/Locality/Tabs/OccurrencesTab.tsx index bd074b782..bee461b72 100644 --- a/frontend/src/components/Locality/Tabs/OccurrencesTab.tsx +++ b/frontend/src/components/Locality/Tabs/OccurrencesTab.tsx @@ -1,4 +1,4 @@ -import { Editable, LocalityDetailsType, LocalitySpecies } from '@/shared/types' +import { Editable, LocalityDetailsType, LocalitySpecies, RowState } from '@/shared/types' import { EditableTable } from '@/components/DetailView/common/EditableTable' import { EditingModal } from '@/components/DetailView/common/EditingModal' import { Grouped } from '@/components/DetailView/common/tabLayoutHelpers' @@ -17,6 +17,7 @@ import { getUniqueLocalityOccurrenceMapExportLocalities, } from '@/components/Species/localitySpeciesMapExport' import { EntryUpdateHistory } from '@/components/DetailView/common/FieldUpdateHistory' +import { useLazyGetLocalityOccurrencesQuery } from '@/redux/localityReducer' const hasMesowearScoreInputs = (row: LocalitySpecies) => { return ( @@ -30,12 +31,13 @@ const hasMesowearScoreInputs = (row: LocalitySpecies) => { } export const OccurrencesTab = () => { - const { mode, data, editData } = useDetailContext() + const { mode, data, editData, setEditData } = useDetailContext() const location = useLocation() const { register, formState: { errors }, } = useForm() + const [refreshOccurrences, { isFetching }] = useLazyGetLocalityOccurrencesQuery() const sortedOccurrenceRows = useMemo(() => { const sourceRows = (mode.read ? data.now_ls : editData.now_ls) as unknown as Editable[] @@ -215,12 +217,6 @@ export const OccurrencesTab = () => { }, ] - // eslint-disable-next-line @typescript-eslint/require-await - const onSave = async () => { - // TODO: Saving logic here (add Occurrence to editData) - return Object.keys(errors).length === 0 - } - const getExportLocalities = (table: MRT_TableInstance) => { const rows = table.getPrePaginationRowModel().rows.map(row => row.original as unknown as LocalitySpecies) return getUniqueLocalityOccurrenceMapExportLocalities(data, rows) @@ -234,20 +230,32 @@ export const OccurrencesTab = () => { await exportOccurrenceMapSvg(table, 'locality-occurrences-map', getExportLocalities) } - const editingModal = ( - - - - - - - - - - ) + const handleRefresh = async () => { + const result = await refreshOccurrences(String(editData.lid)).unwrap() + + const filteredResult = result.filter(row => { + const localRow = editData.now_ls.find(ls => ls.species_id == row.species_id) + return localRow?.rowState !== 'removed' + }) + + const refreshedRows = filteredResult.map(row => ({ + ...row, + rowState: 'clean' as RowState, + })) + + const removedRows = editData.now_ls.filter(row => row.rowState! === 'removed') + + setEditData({ + ...editData, + now_ls: [...refreshedRows, ...removedRows], + }) + } return ( + {!mode.read && ( {!mode.read && ( )} + + , LocalityDetailsType> columns={columns} field="now_ls" From ebe1dfec7e968c6cd8eded9411d973305fd0f598 Mon Sep 17 00:00:00 2001 From: juhanikat Date: Thu, 10 Sep 2026 12:00:42 +0300 Subject: [PATCH 08/23] change cypress config to enable snapshots, and add cypress tests for occurrence creation --- cypress.config.js | 2 +- ...occurrence-edit.cy.ts => occurrence.cy.js} | 19 +++++++++++++++++++ 2 files changed, 20 insertions(+), 1 deletion(-) rename cypress/e2e/{occurrence-edit.cy.ts => occurrence.cy.js} (58%) diff --git a/cypress.config.js b/cypress.config.js index be0cb7e60..63f09db43 100644 --- a/cypress.config.js +++ b/cypress.config.js @@ -143,7 +143,7 @@ module.exports = defineConfig({ specPattern: ['cypress/e2e/**/*.cy.{js,jsx,ts,tsx}', 'cypress/e2e/**/*.spec.{js,jsx,ts,tsx}'], // Performance optimizations experimentalMemoryManagement: true, - numTestsKeptInMemory: 0, + numTestsKeptInMemory: 20, defaultCommandTimeout: 8000, requestTimeout: 8000, responseTimeout: 8000, diff --git a/cypress/e2e/occurrence-edit.cy.ts b/cypress/e2e/occurrence.cy.js similarity index 58% rename from cypress/e2e/occurrence-edit.cy.ts rename to cypress/e2e/occurrence.cy.js index 982d6ddb6..99682ca82 100644 --- a/cypress/e2e/occurrence-edit.cy.ts +++ b/cypress/e2e/occurrence.cy.js @@ -2,6 +2,25 @@ before('Reset database', () => { cy.resetDatabase() }) +describe('Adding occurrence through Locality -> Occurrences tab', () => { + it('opens a new tab', () => { + cy.loginWithSession('testSu') + cy.visit('locality/21050?tab=3', { + onBeforeLoad(win) { + cy.stub(win, 'open').as('windowOpen') + }, + }) + cy.get('#edit-button').should('exist').click() + cy.get('#create-occurrence-button').should('not.be.disabled').click() + cy.get('@windowOpen').should('be.called') + }) + it('is not possible if the locality is being created', () => { + cy.loginWithSession('testSu') + cy.visit('locality/new?tab=3') + cy.get('#create-occurrence-button').should('exist').should('be.disabled') + }) +}) + describe('Occurrence editing', () => { it('allows admin to open edit mode and finalize flow', () => { cy.loginWithSession('testSu') From df459a3a2b7cd46a31ee5cdb0736c7819fea51b0 Mon Sep 17 00:00:00 2001 From: juhanikat Date: Thu, 10 Sep 2026 12:01:32 +0300 Subject: [PATCH 09/23] update occurrence tab visuals --- .../Locality/Tabs/OccurrencesTab.tsx | 32 +++++++++++-------- 1 file changed, 18 insertions(+), 14 deletions(-) diff --git a/frontend/src/components/Locality/Tabs/OccurrencesTab.tsx b/frontend/src/components/Locality/Tabs/OccurrencesTab.tsx index 7f56b6996..c5bb75721 100644 --- a/frontend/src/components/Locality/Tabs/OccurrencesTab.tsx +++ b/frontend/src/components/Locality/Tabs/OccurrencesTab.tsx @@ -256,21 +256,25 @@ export const OccurrencesTab = () => { return ( - {!mode.read && ( - + )} + - )} - + {mode.new &&

Creating new occurrences is only possible after the locality is created.

} + , LocalityDetailsType> columns={columns} From e8aaebc20037138eb8d07fe0aa5774621e177a78 Mon Sep 17 00:00:00 2001 From: juhanikat Date: Thu, 10 Sep 2026 13:04:30 +0300 Subject: [PATCH 10/23] fix infinite request loop in OccurrenceDetails, and add error handler in occurrence route just in case --- .../src/controllers/occurrenceController.ts | 9 ++++- .../Occurrence/OccurrenceDetails.tsx | 38 +++++++++++++------ 2 files changed, 33 insertions(+), 14 deletions(-) diff --git a/backend/src/controllers/occurrenceController.ts b/backend/src/controllers/occurrenceController.ts index e9afb1184..c41e77e32 100644 --- a/backend/src/controllers/occurrenceController.ts +++ b/backend/src/controllers/occurrenceController.ts @@ -10,8 +10,13 @@ type OccurrenceRouteParams = { } export const getOccurrenceDetail = async (req: Request, res: Response) => { - const { lid, speciesId } = parseOccurrenceRouteParams(req.params.lid, req.params.speciesId) - const occurrence = await getOccurrenceByCompositeKey(lid, speciesId, req.user) + let occurrence + try { + const { lid, speciesId } = parseOccurrenceRouteParams(req.params.lid, req.params.speciesId) + occurrence = await getOccurrenceByCompositeKey(lid, speciesId, req.user) + } catch (e) { + return res.status(400).send({ message: 'Unknown error' }) + } if (!occurrence) { return res.status(404).json({ message: 'Occurrence not found' }) diff --git a/frontend/src/components/Occurrence/OccurrenceDetails.tsx b/frontend/src/components/Occurrence/OccurrenceDetails.tsx index 2e5acb854..a7d209cbf 100644 --- a/frontend/src/components/Occurrence/OccurrenceDetails.tsx +++ b/frontend/src/components/Occurrence/OccurrenceDetails.tsx @@ -112,30 +112,44 @@ export const OccurrenceDetails = () => { const parsedLid = lid ? parseInt(lid, 10) : -1 const parsedSpeciesId = speciesId ? parseInt(speciesId, 10) : -1 + + // these two should exist if the occurrence is created through a locality's Occurrences tab + const lidFromSearchParams = searchParams.get('lid') + const locNameFromSearchParams = searchParams.get('loc_name') + + const localityId = lidFromSearchParams ?? lid ?? '' + const { data: occurrenceData, isLoading, - isError, + isError: occurrenceQueryError, } = useGetOccurrenceDetailsQuery( { lid: parsedLid, speciesId: parsedSpeciesId }, { - skip: isNew, + skip: isNew || Number.isNaN(parsedLid) || Number.isNaN(parsedSpeciesId), } ) - const { notify } = useNotify() - const navigate = useNavigate() - const [editLocalityRequest, { isLoading: mutationLoading }] = useEditLocalityMutation() + const { + data: localityData, + isLoading: localityDataLoading, + isError: localityQueryError, + } = useGetLocalityDetailsQuery(localityId) - // these two should exist if the occurrence is created through a locality's Occurrences tab - const lidFromSearchParams = searchParams.get('lid') - const locNameFromSearchParams = searchParams.get('loc_name') + const [editLocalityRequest, { isLoading: mutationLoading }] = useEditLocalityMutation() - const localityId = lidFromSearchParams ?? lid ?? '' - const { data: localityData } = useGetLocalityDetailsQuery(localityId) + const { notify } = useNotify() + const navigate = useNavigate() - if (isError) return
Error loading occurrence data
- if (isLoading || (!occurrenceData && !isNew) || mutationLoading) return + if (isNew && (!lidFromSearchParams || !locNameFromSearchParams)) { + return
Missing search parameters for new occurrence
+ } + if (isNew && (lid || speciesId)) { + return
Error loading data
+ } + if (occurrenceQueryError) return
Error loading occurrence data
+ if (localityQueryError) return
Error loading locality data
+ if (isLoading || mutationLoading || localityDataLoading) return const initialOccurrence = emptyOccurrence From 2e190f23e1d433cc17d58d0cc4658f72329ed190 Mon Sep 17 00:00:00 2001 From: juhanikat Date: Thu, 10 Sep 2026 13:33:13 +0300 Subject: [PATCH 11/23] update occurrence cypress tests, and add id to the new button in table views --- cypress/e2e/locality.cy.js | 35 +++++++++++++++++++ cypress/e2e/occurrence.cy.js | 19 ---------- cypress/e2e/ui.cy.js | 19 +++++----- cypress/e2e/userRights.cy.js | 7 +++- .../src/components/TableView/TableToolBar.tsx | 1 + 5 files changed, 51 insertions(+), 30 deletions(-) diff --git a/cypress/e2e/locality.cy.js b/cypress/e2e/locality.cy.js index 39d1d7c2d..6d0a1fd83 100644 --- a/cypress/e2e/locality.cy.js +++ b/cypress/e2e/locality.cy.js @@ -411,6 +411,10 @@ describe('Creating a new locality', () => { cy.get('[data-value=""]').click() cy.get('[id=max_age-textfield]').should('have.value', '15.97') }) + it('and visiting the occurrence tabs shows the create new occurrence button as disabled', () => { + cy.visit('locality/new?tab=3') + cy.get('#create-occurrence-button').should('exist').should('be.disabled') + }) }) describe('Editing a locality', () => { @@ -493,6 +497,37 @@ describe('Editing a locality', () => { cy.visit(`/locality/20920?tab=5`) cy.contains('wet_screen') }) + + it('and creating a new occurrence opens a new tab', () => { + cy.loginWithSession('testSu') + cy.visit('locality/20920?tab=3', { + onBeforeLoad(win) { + cy.stub(win, 'open').as('windowOpen') + }, + }) + cy.get('#edit-button').should('exist').click() + cy.get('#create-occurrence-button').should('not.be.disabled').click() + cy.get('@windowOpen').should('be.called') + }) + + it('and removing existing occurrence rows, then clicking the refresh occurrences button does not make the rows reappear', () => { + cy.loginWithSession('testSu') + cy.visit('locality/21050?tab=3') + cy.contains('meneghinii').should('exist') + cy.get('#edit-button').should('exist').click() + + cy.get('[data-cy="table-row-85729"]').find('[data-testid=RemoveCircleOutlineIcon]').click() + cy.get('[data-cy="table-row-85729"]').find('[data-testid=AddCircleOutlineIcon]').should('exist') + cy.get('#refresh-occurrences-button').click() + cy.get('[data-cy="table-row-85729"]').find('[data-testid=AddCircleOutlineIcon]').should('exist') + cy.addReferenceAndSave() + cy.visit('locality/21050?tab=3') + cy.contains('meneghinii').should('not.exist') + }) + + it( + 'TODO: adding occurrence to it (in another window), then clicking the refresh occurrences button makes the new occurrence appear' + ) }) describe('Locality table filtering', () => { diff --git a/cypress/e2e/occurrence.cy.js b/cypress/e2e/occurrence.cy.js index 99682ca82..982d6ddb6 100644 --- a/cypress/e2e/occurrence.cy.js +++ b/cypress/e2e/occurrence.cy.js @@ -2,25 +2,6 @@ before('Reset database', () => { cy.resetDatabase() }) -describe('Adding occurrence through Locality -> Occurrences tab', () => { - it('opens a new tab', () => { - cy.loginWithSession('testSu') - cy.visit('locality/21050?tab=3', { - onBeforeLoad(win) { - cy.stub(win, 'open').as('windowOpen') - }, - }) - cy.get('#edit-button').should('exist').click() - cy.get('#create-occurrence-button').should('not.be.disabled').click() - cy.get('@windowOpen').should('be.called') - }) - it('is not possible if the locality is being created', () => { - cy.loginWithSession('testSu') - cy.visit('locality/new?tab=3') - cy.get('#create-occurrence-button').should('exist').should('be.disabled') - }) -}) - describe('Occurrence editing', () => { it('allows admin to open edit mode and finalize flow', () => { cy.loginWithSession('testSu') diff --git a/cypress/e2e/ui.cy.js b/cypress/e2e/ui.cy.js index 938ebff82..69ad17645 100644 --- a/cypress/e2e/ui.cy.js +++ b/cypress/e2e/ui.cy.js @@ -38,6 +38,15 @@ describe('Button Tests', () => { cy.contains('Genus').should('be.visible') }) + it('Occurrences button works', () => { + cy.visit('/') + cy.contains('Occurrences').click() + cy.url().should('include', '/occurrence') + cy.contains('Locality name').should('be.visible') + cy.contains('Genus').should('be.visible') + cy.contains('Species').should('be.visible') + }) + it('Time Units button works', () => { cy.visit('/') cy.contains('Time Units').click() @@ -47,16 +56,6 @@ describe('Button Tests', () => { cy.contains('Sequence').should('be.visible') }) - it('Cross Search button works', () => { - cy.visit('/') - cy.visit('/occurrence') - cy.url().should('include', '/occurrence') - cy.contains('Occurrences').should('be.visible') - cy.contains('Country').should('be.visible') - cy.contains('Genus').should('be.visible') - cy.contains('Species').should('be.visible') - }) - it('Link to species details from species tab', () => { cy.visit('/species') cy.get('tbody tr', { timeout: 10000 }).first().click() diff --git a/cypress/e2e/userRights.cy.js b/cypress/e2e/userRights.cy.js index 8bffc2397..b2e330ff6 100644 --- a/cypress/e2e/userRights.cy.js +++ b/cypress/e2e/userRights.cy.js @@ -10,11 +10,16 @@ describe('Broadly test what different user rights see', () => { it('Sees new button in tableviews', () => { cy.visit('/locality') - cy.contains('New').click() + cy.get('#new-button').click() cy.contains('Time Unit') cy.contains('Creating new locality') }) + it('Does not see new button in Occurrence tableview', () => { + cy.visit('/occurrence') + cy.get('#new-button').should('not.exist') + }) + it('Regions view shows correctly', () => { cy.visit('/region') cy.contains('region 4452477e') diff --git a/frontend/src/components/TableView/TableToolBar.tsx b/frontend/src/components/TableView/TableToolBar.tsx index 1c7378796..0f429d413 100644 --- a/frontend/src/components/TableView/TableToolBar.tsx +++ b/frontend/src/components/TableView/TableToolBar.tsx @@ -209,6 +209,7 @@ export const TableToolBar = ({ {showNewButton && ( - {mode.new &&

Creating new occurrences is only possible after the locality is created.

} + {infoText &&

{infoText}

} , LocalityDetailsType> From f7a17d53c3e81f47ec8da35c87f8a19378bf9119 Mon Sep 17 00:00:00 2001 From: juhanikat Date: Thu, 10 Sep 2026 14:02:16 +0300 Subject: [PATCH 13/23] remove species options that are already occurrences for the locality --- .../src/components/Occurrence/OccurrenceDetails.tsx | 6 +++--- .../components/Occurrence/Tabs/OccurrenceCoreTab.tsx | 10 +++++++--- 2 files changed, 10 insertions(+), 6 deletions(-) diff --git a/frontend/src/components/Occurrence/OccurrenceDetails.tsx b/frontend/src/components/Occurrence/OccurrenceDetails.tsx index a7d209cbf..acc6e103e 100644 --- a/frontend/src/components/Occurrence/OccurrenceDetails.tsx +++ b/frontend/src/components/Occurrence/OccurrenceDetails.tsx @@ -121,7 +121,7 @@ export const OccurrenceDetails = () => { const { data: occurrenceData, - isLoading, + isLoading: occurrenceDataLoading, isError: occurrenceQueryError, } = useGetOccurrenceDetailsQuery( { lid: parsedLid, speciesId: parsedSpeciesId }, @@ -149,7 +149,7 @@ export const OccurrenceDetails = () => { } if (occurrenceQueryError) return
Error loading occurrence data
if (localityQueryError) return
Error loading locality data
- if (isLoading || mutationLoading || localityDataLoading) return + if (occurrenceDataLoading || localityDataLoading || mutationLoading) return const initialOccurrence = emptyOccurrence @@ -214,7 +214,7 @@ export const OccurrenceDetails = () => { } const tabs: TabType[] = [ - { title: 'Occurrence', content: }, + { title: 'Occurrence', content: }, { title: 'Wear', content: }, { title: 'Isotopes', content: }, { diff --git a/frontend/src/components/Occurrence/Tabs/OccurrenceCoreTab.tsx b/frontend/src/components/Occurrence/Tabs/OccurrenceCoreTab.tsx index 2d8da59c3..22277b83d 100644 --- a/frontend/src/components/Occurrence/Tabs/OccurrenceCoreTab.tsx +++ b/frontend/src/components/Occurrence/Tabs/OccurrenceCoreTab.tsx @@ -1,6 +1,6 @@ import { useDetailContext } from '@/components/DetailView/Context/DetailContext' import { ArrayFrame, HalfFrames } from '@/components/DetailView/common/tabLayoutHelpers' -import { EditDataType, LocalityDetailsType, OccurrenceDetailsType, Species, SpeciesDetailsType } from '@/shared/types' +import { LocalitySpeciesDetailsType, OccurrenceDetailsType, Species } from '@/shared/types' import { Link } from 'react-router-dom' import { idStatusOptions, quantityOptions } from '../constants' import { useGetAllSpeciesQuery } from '@/redux/speciesReducer' @@ -43,10 +43,12 @@ const speciesColumns: MRT_ColumnDef[] = [ header: 'Taxon status', }, ] -export const OccurrenceCoreTab = () => { +export const OccurrenceCoreTab = ({ existingOccurrences }: { existingOccurrences: LocalitySpeciesDetailsType[] }) => { const { data: speciesQueryData, isError } = useGetAllSpeciesQuery() const { data, editData, setEditData, mode, textField, dropdown } = useDetailContext() + const existingOccurrenceSpeciesIds = existingOccurrences.map(occurrence => occurrence.species_id) + return ( <> @@ -67,7 +69,9 @@ export const OccurrenceCoreTab = () => { !existingOccurrenceSpeciesIds.includes(species.species_id) + )} title="Species" isError={isError} columns={speciesColumns} From 10e6278a956352582f885b3244b6e6b5300e51b9 Mon Sep 17 00:00:00 2001 From: juhanikat Date: Thu, 10 Sep 2026 14:13:55 +0300 Subject: [PATCH 14/23] revert change to occurrence route error handling --- backend/src/controllers/occurrenceController.ts | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/backend/src/controllers/occurrenceController.ts b/backend/src/controllers/occurrenceController.ts index c41e77e32..e9afb1184 100644 --- a/backend/src/controllers/occurrenceController.ts +++ b/backend/src/controllers/occurrenceController.ts @@ -10,13 +10,8 @@ type OccurrenceRouteParams = { } export const getOccurrenceDetail = async (req: Request, res: Response) => { - let occurrence - try { - const { lid, speciesId } = parseOccurrenceRouteParams(req.params.lid, req.params.speciesId) - occurrence = await getOccurrenceByCompositeKey(lid, speciesId, req.user) - } catch (e) { - return res.status(400).send({ message: 'Unknown error' }) - } + const { lid, speciesId } = parseOccurrenceRouteParams(req.params.lid, req.params.speciesId) + const occurrence = await getOccurrenceByCompositeKey(lid, speciesId, req.user) if (!occurrence) { return res.status(404).json({ message: 'Occurrence not found' }) From 7cc58856b2a68e0eaf5b055d073f6c489bd4c3f1 Mon Sep 17 00:00:00 2001 From: juhanikat Date: Thu, 10 Sep 2026 14:14:17 +0300 Subject: [PATCH 15/23] add species unique identifier to occurrence details --- frontend/src/components/Occurrence/Tabs/OccurrenceCoreTab.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/frontend/src/components/Occurrence/Tabs/OccurrenceCoreTab.tsx b/frontend/src/components/Occurrence/Tabs/OccurrenceCoreTab.tsx index 22277b83d..4525ec231 100644 --- a/frontend/src/components/Occurrence/Tabs/OccurrenceCoreTab.tsx +++ b/frontend/src/components/Occurrence/Tabs/OccurrenceCoreTab.tsx @@ -94,6 +94,7 @@ export const OccurrenceCoreTab = ({ existingOccurrences }: { existingOccurrences ['Family', editData.family_name], ['Genus', editData.genus_name], ['Species', editData.species_name], + ['Unique Identifier', editData.unique_identifier], ['ID status', dropdown('id_status', idStatusOptions, 'ID status')], ['Additional Information', textField('orig_entry', { type: 'text' })], ['Source name', textField('source_name', { type: 'text' })], From 0d2d59a8c01f6aa9ced51872448f8ca229627026 Mon Sep 17 00:00:00 2001 From: juhanikat Date: Thu, 10 Sep 2026 14:21:11 +0300 Subject: [PATCH 16/23] fix lint and tsc issues, remove unneeded code, and remove useOccurrenceDetails hook --- .../Locality/Tabs/OccurrencesTab.tsx | 7 +- .../Tabs/__tests__/OccurrenceCoreTab.test.tsx | 4 +- .../__tests__/OccurrenceDetails.test.tsx | 181 ------------------ .../src/hooks/useOccurrenceDetails.test.tsx | 88 --------- frontend/src/hooks/useOccurrenceDetails.ts | 35 ---- 5 files changed, 3 insertions(+), 312 deletions(-) delete mode 100644 frontend/src/components/Occurrence/__tests__/OccurrenceDetails.test.tsx delete mode 100644 frontend/src/hooks/useOccurrenceDetails.test.tsx delete mode 100644 frontend/src/hooks/useOccurrenceDetails.ts diff --git a/frontend/src/components/Locality/Tabs/OccurrencesTab.tsx b/frontend/src/components/Locality/Tabs/OccurrencesTab.tsx index e79c199fa..b534b5642 100644 --- a/frontend/src/components/Locality/Tabs/OccurrencesTab.tsx +++ b/frontend/src/components/Locality/Tabs/OccurrencesTab.tsx @@ -16,7 +16,6 @@ import RefreshIcon from '@mui/icons-material/Refresh' import { Box, Button } from '@mui/material' import { MRT_ColumnDef, MRT_Row, MRT_RowData, MRT_TableInstance } from 'material-react-table' import { useMemo } from 'react' -import { useForm } from 'react-hook-form' import { useLocation } from 'react-router-dom' const hasMesowearScoreInputs = (row: LocalitySpecies) => { @@ -32,12 +31,8 @@ const hasMesowearScoreInputs = (row: LocalitySpecies) => { export const OccurrencesTab = () => { const { mode, data, editData, setEditData } = useDetailContext() - const location = useLocation() - const { - register, - formState: { errors }, - } = useForm() const [refreshOccurrences, { isFetching }] = useLazyGetLocalityOccurrencesQuery() + const location = useLocation() const sortedOccurrenceRows = useMemo(() => { const sourceRows = (mode.read ? data.now_ls : editData.now_ls) as unknown as Editable[] diff --git a/frontend/src/components/Occurrence/Tabs/__tests__/OccurrenceCoreTab.test.tsx b/frontend/src/components/Occurrence/Tabs/__tests__/OccurrenceCoreTab.test.tsx index 9271333ef..4f5f1a16a 100644 --- a/frontend/src/components/Occurrence/Tabs/__tests__/OccurrenceCoreTab.test.tsx +++ b/frontend/src/components/Occurrence/Tabs/__tests__/OccurrenceCoreTab.test.tsx @@ -55,7 +55,7 @@ describe('OccurrenceCoreTab', () => { render( - + ) @@ -88,7 +88,7 @@ describe('OccurrenceCoreTab', () => { render( - + ) diff --git a/frontend/src/components/Occurrence/__tests__/OccurrenceDetails.test.tsx b/frontend/src/components/Occurrence/__tests__/OccurrenceDetails.test.tsx deleted file mode 100644 index 582d8f86b..000000000 --- a/frontend/src/components/Occurrence/__tests__/OccurrenceDetails.test.tsx +++ /dev/null @@ -1,181 +0,0 @@ -import { beforeEach, describe, expect, it, jest } from '@jest/globals' -import { fireEvent, render, screen, waitFor } from '@testing-library/react' -import { MemoryRouter, Route, Routes } from 'react-router-dom' -import { OccurrenceDetails } from '../OccurrenceDetails' -import { useOccurrenceDetails } from '@/hooks/useOccurrenceDetails' - -const notify = jest.fn() - -jest.mock('@/hooks/useOccurrenceDetails', () => ({ - useOccurrenceDetails: jest.fn(), -})) - -jest.mock('@/hooks/notification', () => ({ - useNotify: () => ({ notify, setMessage: jest.fn() }), - getErrorMessage: (error: { message?: string }, fallback: string) => error.message ?? fallback, -})) - -jest.mock('../Tabs/OccurrenceCoreTab', () => ({ OccurrenceCoreTab: () =>
Occurrence tab
})) -jest.mock('../Tabs/OccurrenceWearTab', () => ({ OccurrenceWearTab: () =>
Wear tab
})) -jest.mock('../Tabs/OccurrenceIsotopeTab', () => ({ OccurrenceIsotopeTab: () =>
Isotope tab
})) -jest.mock('@/components/DetailView/common/UpdateTab', () => ({ UpdateTab: () =>
Updates placeholder
})) - -jest.mock('@/components/DetailView/DetailView', () => ({ - DetailView: ({ - tabs, - onWrite, - data, - }: { - tabs: Array<{ title: string }> - onWrite?: (data: unknown) => Promise - data: unknown - }) => ( -
-
{tabs.map(tab => tab.title).join('|')}
- -
- ), -})) - -const mockUseOccurrenceDetails = useOccurrenceDetails as jest.MockedFunction - -const occurrenceData = { - lid: 1, - species_id: 2, - loc_status: false, - loc_name: 'Loc', - country: 'Country', - genus_name: 'Genus', - family_name: 'Family', - species_name: 'species', - unique_identifier: null, - dms_lat: null, - dms_long: null, - bfa_max: null, - bfa_min: null, - max_age: null, - min_age: null, - nis: null, - pct: null, - quad: null, - mni: null, - qua: null, - id_status: null, - orig_entry: null, - source_name: null, - body_mass: 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, - microwear: null, - dc13_mean: null, - dc13_n: null, - dc13_max: null, - dc13_min: null, - dc13_stdev: null, - do18_mean: null, - do18_n: null, - do18_max: null, - do18_min: null, - do18_stdev: null, - now_oau: [], -} - -describe('OccurrenceDetails', () => { - beforeEach(() => { - mockUseOccurrenceDetails.mockReset() - notify.mockReset() - }) - - it('renders occurrence tabs including Updates placeholder tab', () => { - mockUseOccurrenceDetails.mockReturnValue({ - occurrence: occurrenceData, - isLoading: false, - isError: false, - isSaving: false, - refetch: jest.fn(() => Promise.resolve(undefined)), - saveOccurrence: jest.fn(() => Promise.resolve({ lid: 1, species_id: 2 } as never)), - }) - - render( - - - } /> - - - ) - - expect(screen.getByTestId('occurrence-detail-view').textContent).toContain('Occurrence|Wear|Isotopes|Updates') - }) - - it('shows success notification after successful save', async () => { - const saveOccurrence = jest.fn(() => Promise.resolve({ ...occurrenceData } as never)) - - mockUseOccurrenceDetails.mockReturnValue({ - occurrence: occurrenceData, - isLoading: false, - isError: false, - isSaving: false, - refetch: jest.fn(() => Promise.resolve(undefined)), - saveOccurrence, - }) - - render( - - - } /> - - - ) - - fireEvent.click(screen.getByTestId('trigger-on-write')) - - await waitFor(() => { - expect(saveOccurrence).toHaveBeenCalledTimes(1) - expect(notify).toHaveBeenCalledWith('Occurrence entry finalized successfully.') - }) - }) - - it('shows validation message when save fails', async () => { - const saveOccurrence = jest.fn(() => Promise.reject(new Error('Validation failed: Quantity is required'))) - - mockUseOccurrenceDetails.mockReturnValue({ - occurrence: occurrenceData, - isLoading: false, - isError: false, - isSaving: false, - refetch: jest.fn(() => Promise.resolve(undefined)), - saveOccurrence, - }) - - render( - - - } /> - - - ) - - fireEvent.click(screen.getByTestId('trigger-on-write')) - - await waitFor(() => { - expect(saveOccurrence).toHaveBeenCalledTimes(1) - expect(notify).toHaveBeenCalledWith('Validation failed: Quantity is required', 'error') - }) - }) -}) diff --git a/frontend/src/hooks/useOccurrenceDetails.test.tsx b/frontend/src/hooks/useOccurrenceDetails.test.tsx deleted file mode 100644 index b4306e422..000000000 --- a/frontend/src/hooks/useOccurrenceDetails.test.tsx +++ /dev/null @@ -1,88 +0,0 @@ -import { beforeEach, describe, expect, it, jest } from '@jest/globals' -import { renderHook } from '@testing-library/react' -import { useOccurrenceDetails } from './useOccurrenceDetails' -import { useEditOccurrenceMutation, useGetOccurrenceDetailsQuery } from '@/redux/api' - -jest.mock('@/redux/api', () => ({ - useGetOccurrenceDetailsQuery: jest.fn(), - useEditOccurrenceMutation: jest.fn(), -})) - -const mockUseGetOccurrenceDetailsQuery = useGetOccurrenceDetailsQuery as jest.MockedFunction< - typeof useGetOccurrenceDetailsQuery -> -const mockUseEditOccurrenceMutation = useEditOccurrenceMutation as jest.MockedFunction - -describe('useOccurrenceDetails', () => { - beforeEach(() => { - mockUseGetOccurrenceDetailsQuery.mockReset() - mockUseEditOccurrenceMutation.mockReset() - }) - - it('passes lid and speciesId to query hook and returns mapped data', () => { - const refetch = jest.fn(() => Promise.resolve(undefined)) - mockUseGetOccurrenceDetailsQuery.mockReturnValue({ - data: { lid: 10, species_id: 20, loc_name: 'Loc' }, - isLoading: false, - isFetching: false, - isError: false, - refetch, - } as unknown as ReturnType) - - mockUseEditOccurrenceMutation.mockReturnValue([jest.fn(), { isLoading: false }] as unknown as ReturnType< - typeof useEditOccurrenceMutation - >) - - const { result } = renderHook(() => useOccurrenceDetails(10, 20)) - - expect(mockUseGetOccurrenceDetailsQuery).toHaveBeenCalledWith({ lid: 10, speciesId: 20 }) - expect(result.current.occurrence?.lid).toBe(10) - expect(result.current.occurrence?.species_id).toBe(20) - expect(result.current.isLoading).toBe(false) - expect(result.current.isError).toBe(false) - expect(result.current.isSaving).toBe(false) - }) - - it('reports loading when query is loading or fetching', () => { - mockUseGetOccurrenceDetailsQuery.mockReturnValue({ - data: undefined, - isLoading: false, - isFetching: true, - isError: false, - refetch: jest.fn(() => Promise.resolve(undefined)), - } as unknown as ReturnType) - - mockUseEditOccurrenceMutation.mockReturnValue([jest.fn(), { isLoading: false }] as unknown as ReturnType< - typeof useEditOccurrenceMutation - >) - - const { result } = renderHook(() => useOccurrenceDetails(10, 20)) - - expect(result.current.isLoading).toBe(true) - }) - - it('saveOccurrence calls edit mutation with expected payload', async () => { - const refetch = jest.fn(() => Promise.resolve(undefined)) - mockUseGetOccurrenceDetailsQuery.mockReturnValue({ - data: undefined, - isLoading: false, - isFetching: false, - isError: false, - refetch, - } as unknown as ReturnType) - - const unwrap = jest.fn<() => Promise>().mockResolvedValue({ lid: 10, species_id: 20 }) - const editOccurrence = jest.fn(() => ({ unwrap })) - - mockUseEditOccurrenceMutation.mockReturnValue([editOccurrence, { isLoading: false }] as unknown as ReturnType< - typeof useEditOccurrenceMutation - >) - - const { result } = renderHook(() => useOccurrenceDetails(10, 20)) - - await result.current.saveOccurrence({ qua: 'a' }) - - expect(editOccurrence).toHaveBeenCalledWith({ lid: 10, speciesId: 20, occurrence: { qua: 'a' } }) - expect(unwrap).toHaveBeenCalled() - }) -}) diff --git a/frontend/src/hooks/useOccurrenceDetails.ts b/frontend/src/hooks/useOccurrenceDetails.ts deleted file mode 100644 index 8bf1ec2db..000000000 --- a/frontend/src/hooks/useOccurrenceDetails.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { skipToken } from '@reduxjs/toolkit/query' -import { EditableOccurrenceData, OccurrenceDetailsType } from '@/shared/types' -import { useEditOccurrenceMutation, useGetOccurrenceDetailsQuery } from '@/redux/api' - -type UseOccurrenceDetailsResult = { - occurrence: OccurrenceDetailsType | undefined - isLoading: boolean - isError: boolean - isSaving: boolean - refetch: () => Promise - saveOccurrence: (occurrence: EditableOccurrenceData) => Promise -} - -export const useOccurrenceDetails = (lid: number | null, speciesId: number | null): UseOccurrenceDetailsResult => { - const queryArg = lid !== null && speciesId !== null ? { lid, speciesId } : skipToken - const occurrenceQuery = useGetOccurrenceDetailsQuery(queryArg) - const [editOccurrence, editOccurrenceState] = useEditOccurrenceMutation() - - const saveOccurrence = async (occurrence: EditableOccurrenceData): Promise => { - if (lid === null || speciesId === null) { - throw new Error('Occurrence ids are required for saving') - } - - return await editOccurrence({ lid, speciesId, occurrence }).unwrap() - } - - return { - occurrence: occurrenceQuery.data, - isLoading: occurrenceQuery.isLoading || occurrenceQuery.isFetching, - isError: occurrenceQuery.isError, - isSaving: editOccurrenceState.isLoading, - refetch: occurrenceQuery.refetch, - saveOccurrence, - } -} From 8f8284e82435c976393e705a024e36e505f57062 Mon Sep 17 00:00:00 2001 From: juhanikat Date: Thu, 17 Sep 2026 13:52:43 +0300 Subject: [PATCH 17/23] update textfield component and occurrence vaildators --- .../DetailView/common/editingComponents.tsx | 48 +++++++++---------- frontend/src/shared/validators/occurrence.ts | 24 ++++++++-- 2 files changed, 43 insertions(+), 29 deletions(-) diff --git a/frontend/src/components/DetailView/common/editingComponents.tsx b/frontend/src/components/DetailView/common/editingComponents.tsx index 26c20f7a0..04f2ddf35 100755 --- a/frontend/src/components/DetailView/common/editingComponents.tsx +++ b/frontend/src/components/DetailView/common/editingComponents.tsx @@ -313,7 +313,6 @@ export const EditableTextField = (props: EditableTextFieldProp const value = event?.currentTarget?.value if (type === 'number') { const setNumberValue = handleSetEditData as EditableTextFieldNumberProps['handleSetEditData'] - const allowNegative = min === undefined || min < 0 if (value === '') { setNumberInputValue('') if (setNumberValue) { @@ -326,24 +325,12 @@ export const EditableTextField = (props: EditableTextFieldProp return } - if (integerOnly) { - if (!isAllowedIntegerInputValue(value, allowNegative)) return - } else { - if (!isAllowedNumberInputValue(value)) return - } - setNumberInputValue(value) - const asNumber = Number(value) - if ( - (integerOnly ? isPartialIntegerInputValue(value, allowNegative) : isPartialNumberInputValue(value)) || - Number.isNaN(asNumber) - ) - return - if (setNumberValue) { + if (setNumberValue && !Number.isNaN(asNumber)) { setNumberValue(asNumber) } else { - const nextEditData = { ...editData, [field]: asNumber } + const nextEditData = { ...editData, [field]: value } setEditData(nextEditData) updateFieldErrors(nextEditData) } @@ -381,12 +368,31 @@ export const EditableTextField = (props: EditableTextFieldProp const setNumberValue = handleSetEditData as EditableTextFieldNumberProps['handleSetEditData'] if (value === '') return const allowNegative = min === undefined || min < 0 - if (integerOnly ? isPartialIntegerInputValue(value, allowNegative) : isPartialNumberInputValue(value)) { + if ( + integerOnly + ? isAllowedIntegerInputValue(value, allowNegative) && !/^-?\d+$/.test(value) + : isAllowedNumberInputValue(value) && !/^(-?\d+(\.\d*)?|\d+\.\d+)$/.test(value) + ) { + setNumberInputValue('') + if (setNumberValue) setNumberValue('') + else setEditData({ ...editData, [field]: '' }) + return + } + + if (integerOnly && !isAllowedIntegerInputValue(value, allowNegative)) { setNumberInputValue('') if (setNumberValue) setNumberValue('') else setEditData({ ...editData, [field]: '' }) return } + + if (!integerOnly && !isAllowedNumberInputValue(value)) { + setNumberInputValue('') + if (setNumberValue) setNumberValue('') + else setEditData({ ...editData, [field]: '' }) + return + } + const asNumber = Number(value) if (Number.isNaN(asNumber)) return setNumberInputValue(String(asNumber)) @@ -396,25 +402,19 @@ export const EditableTextField = (props: EditableTextFieldProp { - if (type !== 'number') return - if (event.key === 'e' || event.key === 'E' || event.key === '+') event.preventDefault() - if (integerOnly && event.key === '.') event.preventDefault() - }} id={`${String(field)}-textfield`} - value={type === 'number' ? numberInputValue : editData[field] ?? ''} + value={editData[field] ?? ''} variant="outlined" size="small" error={!!error} helperText={error ?? ''} - type={type} multiline={big} disabled={disabled} onBlur={() => { if (type === 'number') handleNumberBlur() if (trim) trimValue() }} - InputProps={readonly ? { readOnly: true } : { readOnly: false }} + slotProps={{ input: { readOnly: readonly ? true : false } }} /> ) diff --git a/frontend/src/shared/validators/occurrence.ts b/frontend/src/shared/validators/occurrence.ts index e9c2495e0..5f06539ff 100644 --- a/frontend/src/shared/validators/occurrence.ts +++ b/frontend/src/shared/validators/occurrence.ts @@ -1,5 +1,5 @@ -import { EditableOccurrenceData } from '../types' -import { Validators, validator } from './validator' +import { EditDataType, OccurrenceDetailsType } from '../types' +import { validateFields, validator, Validators } from './validator' export const occurrenceDropdownValues = { idStatus: ['family id uncertain', 'genus id uncertain', 'species id uncertain'] as const, @@ -44,8 +44,10 @@ const validateDecimalNumber = (name: string, value: number) => { return } -export const validateOccurrence = (editData: EditableOccurrenceData, fieldName: keyof EditableOccurrenceData) => { - const validators: Validators> = { +const createOccurrenceValidators = ( + editData: EditDataType +): Validators>> => { + return { id_status: { name: 'ID status', condition: () => editData.id_status !== null && editData.id_status !== undefined && editData.id_status !== '', @@ -155,6 +157,18 @@ export const validateOccurrence = (editData: EditableOccurrenceData, fieldName: }, do18_stdev: { name: 'δ18O Stdev', asNumber: value => validatePositiveDecimal('δ18O Stdev', value) }, } +} + +export const validateOccurrence = ( + editData: EditDataType, + fieldName: keyof EditDataType +) => { + return validator>(createOccurrenceValidators(editData), editData, fieldName) +} - return validator(validators, editData, fieldName) +export const validateOccurrenceFields = (editData: Partial>) => { + return validateFields>( + createOccurrenceValidators(editData as EditDataType), + editData + ) } From 0b07fca3524ad24a6de05dac1a8b28baafc2ca09 Mon Sep 17 00:00:00 2001 From: juhanikat Date: Thu, 17 Sep 2026 14:04:43 +0300 Subject: [PATCH 18/23] update occurrence creation inisde locality -> occurrence tab --- .../DetailView/common/editingComponents.tsx | 21 +- .../Locality/Tabs/OccurrencesTab.tsx | 254 ++++++++++++++---- .../Occurrence/Tabs/OccurrenceCoreTab.tsx | 26 +- 3 files changed, 225 insertions(+), 76 deletions(-) diff --git a/frontend/src/components/DetailView/common/editingComponents.tsx b/frontend/src/components/DetailView/common/editingComponents.tsx index 04f2ddf35..9fcf5198c 100755 --- a/frontend/src/components/DetailView/common/editingComponents.tsx +++ b/frontend/src/components/DetailView/common/editingComponents.tsx @@ -368,25 +368,10 @@ export const EditableTextField = (props: EditableTextFieldProp const setNumberValue = handleSetEditData as EditableTextFieldNumberProps['handleSetEditData'] if (value === '') return const allowNegative = min === undefined || min < 0 - if ( - integerOnly - ? isAllowedIntegerInputValue(value, allowNegative) && !/^-?\d+$/.test(value) - : isAllowedNumberInputValue(value) && !/^(-?\d+(\.\d*)?|\d+\.\d+)$/.test(value) - ) { - setNumberInputValue('') - if (setNumberValue) setNumberValue('') - else setEditData({ ...editData, [field]: '' }) - return - } - - if (integerOnly && !isAllowedIntegerInputValue(value, allowNegative)) { - setNumberInputValue('') - if (setNumberValue) setNumberValue('') - else setEditData({ ...editData, [field]: '' }) - return - } + const invalidIntegerValue = integerOnly && !isAllowedIntegerInputValue(value, allowNegative) + const invalidDecimalValue = !integerOnly && !isAllowedNumberInputValue(value) - if (!integerOnly && !isAllowedNumberInputValue(value)) { + if (invalidIntegerValue || invalidDecimalValue) { setNumberInputValue('') if (setNumberValue) setNumberValue('') else setEditData({ ...editData, [field]: '' }) diff --git a/frontend/src/components/Locality/Tabs/OccurrencesTab.tsx b/frontend/src/components/Locality/Tabs/OccurrencesTab.tsx index b534b5642..031fe44cd 100644 --- a/frontend/src/components/Locality/Tabs/OccurrencesTab.tsx +++ b/frontend/src/components/Locality/Tabs/OccurrencesTab.tsx @@ -1,22 +1,48 @@ import { applyDefaultSpeciesOrdering, hasActiveSortingInSearch } from '@/components/DetailView/common/DetailTabTable' import { EditableTable } from '@/components/DetailView/common/EditableTable' +import { EditingModal } from '@/components/DetailView/common/EditingModal' import { EntryUpdateHistory } from '@/components/DetailView/common/FieldUpdateHistory' import { Grouped } from '@/components/DetailView/common/tabLayoutHelpers' -import { useDetailContext } from '@/components/DetailView/Context/DetailContext' +import { + DetailContextProvider, + modeOptionToMode, + useDetailContext, +} from '@/components/DetailView/Context/DetailContext' +import { FieldsWithErrorsType, OptionalRadioSelectionProps, TextFieldOptions } from '@/components/DetailView/DetailView' +import { emptyOccurrence } from '@/components/Occurrence/OccurrenceDetails' +import { OccurrenceCoreTab } from '@/components/Occurrence/Tabs/OccurrenceCoreTab' +import { OccurrenceIsotopeTab } from '@/components/Occurrence/Tabs/OccurrenceIsotopeTab' +import { OccurrenceWearTab } from '@/components/Occurrence/Tabs/OccurrenceWearTab' import { exportOccurrenceMapKml, exportOccurrenceMapSvg, getUniqueLocalityOccurrenceMapExportLocalities, } from '@/components/Species/localitySpeciesMapExport' import { occurrenceLabels } from '@/constants/occurrenceLabels' -import { useLazyGetLocalityOccurrencesQuery } from '@/redux/localityReducer' -import { Editable, LocalityDetailsType, LocalitySpecies, RowState } from '@/shared/types' +import { useNotify } from '@/hooks/notification' +import type { MRT_ColumnDef, MRT_Row, MRT_RowData, MRT_TableInstance } from 'material-react-table' +import { + Editable, + EditDataType, + LocalityDetailsType, + LocalitySpeciesDetailsType, + LocalitySpecies, + OccurrenceDetailsType, +} from '@/shared/types' import { calculateNormalizedMesowearScore } from '@/shared/utils/mesowear' -import RefreshIcon from '@mui/icons-material/Refresh' -import { Box, Button } from '@mui/material' -import { MRT_ColumnDef, MRT_Row, MRT_RowData, MRT_TableInstance } from 'material-react-table' -import { useMemo } from 'react' +import { validateOccurrence, validateOccurrenceFields } from '@/shared/validators/occurrence' +import { ValidationObject } from '@/shared/validators/validator' +import { Box, Button, DialogActions, DialogContent } from '@mui/material' +import { useMemo, useState } from 'react' import { useLocation } from 'react-router-dom' +import SaveIcon from '@mui/icons-material/Save' +import { + DropdownSelector, + DropdownSelectorWithSearch, + DropdownOption, + EditableTextField, + RadioSelector, +} from '@/components/DetailView/common/editingComponents' const hasMesowearScoreInputs = (row: LocalitySpecies) => { return ( @@ -29,14 +55,84 @@ const hasMesowearScoreInputs = (row: LocalitySpecies) => { ) } +const NewOccurrenceDialogContent = ({ + onSave, + onClose, + localityData, + validateOccurrenceFields, +}: { + onSave: (occurrence: EditDataType) => void + onClose: () => void + localityData: EditDataType | undefined + validateOccurrenceFields: (editData: EditDataType) => ValidationObject[] +}) => { + const { editData, fieldsWithErrors, setFieldsWithErrors } = useDetailContext() + const { notify } = useNotify() + + const validateAllFields = () => { + const nextFieldsWithErrors: FieldsWithErrorsType = {} + + for (const errorObject of validateOccurrenceFields(editData)) { + nextFieldsWithErrors[String(errorObject.field ?? errorObject.name)] = errorObject + } + + setFieldsWithErrors(() => nextFieldsWithErrors) + return Object.keys(nextFieldsWithErrors).length === 0 + } + + const handleSave = () => { + if (!validateAllFields()) { + notify('Please fix occurrence validation errors before saving.', 'error') + return + } + + try { + notify('Saved occurrence successfully.') + const occurrenceToSave: EditDataType = { + ...editData, + lid: editData.lid ?? localityData?.lid ?? 0, + loc_name: editData.loc_name ?? localityData?.loc_name ?? '', + } + + onSave(occurrenceToSave) + onClose() + } catch (e) { + notify('something went wrong', 'error') + } + } + + return ( + <> + + } + /> + + + + + + + + + ) +} + export const OccurrencesTab = () => { const { mode, data, editData, setEditData } = useDetailContext() - const [refreshOccurrences, { isFetching }] = useLazyGetLocalityOccurrencesQuery() + const [fieldsWithErrors, setFieldsWithErrors] = useState({}) const location = useLocation() const sortedOccurrenceRows = useMemo(() => { const sourceRows = (mode.read ? data.now_ls : editData.now_ls) as unknown as Editable[] - return ( applyDefaultSpeciesOrdering(sourceRows, { prefix: 'com_species', @@ -225,57 +321,115 @@ export const OccurrencesTab = () => { await exportOccurrenceMapSvg(table, 'locality-occurrences-map', getExportLocalities) } - const handleRefresh = async () => { - // Since Occurrences are created in a new tab when clicking the "Create new Occurrence" button - // in this tab, the data shown in this tab is not updated automatically by redux cache invalidation - // once the new occurrence is created. Thus refresh button. - const result = await refreshOccurrences(String(editData.lid)).unwrap() + const textField = (field: keyof EditDataType, options?: TextFieldOptions) => ( + field={field} {...options} /> + ) - const filteredResult = result.filter(row => { - const localRow = editData.now_ls.find(ls => ls.species_id == row.species_id) - return localRow?.rowState !== 'removed' - }) + const dropdown = ( + field: keyof EditDataType, + options: Array, + name: string, + disabled?: boolean + ) => field={field} options={options} name={name} disabled={disabled} /> - const refreshedRows = filteredResult.map(row => ({ - ...row, - rowState: 'clean' as RowState, - })) + const dropdownWithSearch = ( + field: keyof EditDataType, + options: Array, + name: string, + disabled?: boolean, + label?: string + ) => ( + + field={field} + options={options} + name={name} + disabled={disabled} + label={label} + /> + ) - const removedRows = editData.now_ls.filter(row => row.rowState! === 'removed') + const radioSelection = ( + field: keyof EditDataType, + options: Array, + name: string, + optionalRadioSelectionProps?: OptionalRadioSelectionProps + ) => ( + + field={field} + options={options} + name={name} + {...optionalRadioSelectionProps} + /> + ) - setEditData({ - ...editData, - now_ls: [...refreshedRows, ...removedRows], - }) - } + const bigTextField = (field: keyof EditDataType) => ( + field={field} type="text" big /> + ) - let infoText - if (mode.new) infoText = 'Creating new occurrences is only possible after the locality is created.' - else if (!mode.read) { - infoText = - 'Clicking the button opens a new tab. After the occurrence has been created successfully, click the refresh button to update this table.' - } + const newOccurrenceContextData = useMemo( + () => ({ + ...emptyOccurrence, + lid: editData.lid ?? 0, + loc_name: editData.loc_name ?? '', + }), + [editData.lid, editData.loc_name] + ) return ( - {!mode.read && ( - + {!mode.read && !mode.new && ( + + {({ close }) => ( + + contextState={{ + data: newOccurrenceContextData, + mode: modeOptionToMode.new, + setMode: () => undefined, + editData: newOccurrenceContextData as EditDataType, + textField, + dropdown, + dropdownWithSearch, + radioSelection, + bigTextField, + validator: validateOccurrence, + validateFields: validateOccurrenceFields, + fieldsWithErrors, + setFieldsWithErrors, + }} + > + ) => { + const appendedOccurrence = { + ...newOccurrence, + lid: editData.lid, + species_id: newOccurrence.species_id ?? 0, + rowState: 'new', + com_species: { + com_taxa_synonym: [], + now_sau: [], + species_id: newOccurrence.species_id ?? 0, + family_name: newOccurrence.family_name ?? null, + genus_name: newOccurrence.genus_name ?? null, + species_name: newOccurrence.species_name ?? null, + unique_identifier: newOccurrence.unique_identifier ?? null, + now_ls: [], + }, + } as unknown as LocalitySpeciesDetailsType + + setEditData({ + ...editData, + now_ls: [...editData.now_ls, appendedOccurrence], + }) + }} + validateOccurrenceFields={validateOccurrenceFields} + /> + + )} + )} - - {infoText &&

{infoText}

}
, LocalityDetailsType> diff --git a/frontend/src/components/Occurrence/Tabs/OccurrenceCoreTab.tsx b/frontend/src/components/Occurrence/Tabs/OccurrenceCoreTab.tsx index 4525ec231..b8f9fa821 100644 --- a/frontend/src/components/Occurrence/Tabs/OccurrenceCoreTab.tsx +++ b/frontend/src/components/Occurrence/Tabs/OccurrenceCoreTab.tsx @@ -43,11 +43,26 @@ const speciesColumns: MRT_ColumnDef[] = [ header: 'Taxon status', }, ] -export const OccurrenceCoreTab = ({ existingOccurrences }: { existingOccurrences: LocalitySpeciesDetailsType[] }) => { +export const OccurrenceCoreTab = ({ + clickableLocName = true, + existingOccurrences, +}: { + clickableLocName?: boolean + existingOccurrences?: LocalitySpeciesDetailsType[] +}) => { const { data: speciesQueryData, isError } = useGetAllSpeciesQuery() const { data, editData, setEditData, mode, textField, dropdown } = useDetailContext() - const existingOccurrenceSpeciesIds = existingOccurrences.map(occurrence => occurrence.species_id) + const existingOccurrenceSpeciesIds = (existingOccurrences ?? []).map(occurrence => occurrence.species_id) + + const locNameArray = clickableLocName + ? [ + 'Locality', + + {toText(data.loc_name)} + , + ] + : ['Locality', toText(data.loc_name)] return ( <> @@ -57,12 +72,7 @@ export const OccurrenceCoreTab = ({ existingOccurrences }: { existingOccurrences key="identification" title="Identification" array={[ - [ - 'Locality', - - {toText(data.loc_name)} - , - ], + locNameArray, !mode.read ? [ '', From 993e53b168ef81fe1a8ee19379bc83f47a36d0b0 Mon Sep 17 00:00:00 2001 From: juhanikat Date: Thu, 17 Sep 2026 14:05:08 +0300 Subject: [PATCH 19/23] small fix to occurrencedetails --- .../components/Occurrence/OccurrenceDetails.tsx | 17 +++++------------ 1 file changed, 5 insertions(+), 12 deletions(-) diff --git a/frontend/src/components/Occurrence/OccurrenceDetails.tsx b/frontend/src/components/Occurrence/OccurrenceDetails.tsx index acc6e103e..dd6dc3458 100644 --- a/frontend/src/components/Occurrence/OccurrenceDetails.tsx +++ b/frontend/src/components/Occurrence/OccurrenceDetails.tsx @@ -12,20 +12,12 @@ import { LocalityDetailsType, OccurrenceDetailsType, } from '@/shared/types' -import { validateOccurrence } from '@/shared/validators/occurrence' +import { validateOccurrence, validateOccurrenceFields } from '@/shared/validators/occurrence' import { getErrorMessage, useNotify } from '@/hooks/notification' -import { ValidationObject } from '@/shared/validators/validator' import { useGetOccurrenceDetailsQuery } from '@/redux/api' import { useEditLocalityMutation, useGetLocalityDetailsQuery } from '@/redux/localityReducer' -const validateOccurrenceDetail = ( - editData: EditDataType, - fieldName: keyof EditDataType -): ValidationObject => { - return validateOccurrence(editData as EditableOccurrenceData, fieldName as keyof EditableOccurrenceData) -} - -const emptyOccurrence: OccurrenceDetailsType = { +export const emptyOccurrence: OccurrenceDetailsType = { lid: 0, species_id: 0, loc_status: null, @@ -214,7 +206,7 @@ export const OccurrenceDetails = () => { } const tabs: TabType[] = [ - { title: 'Occurrence', content: }, + { title: 'Occurrence', content: }, { title: 'Wear', content: }, { title: 'Isotopes', content: }, { @@ -228,7 +220,8 @@ export const OccurrenceDetails = () => { tabs={tabs} data={occurrenceData ?? initialOccurrence} isNew={isNew} - validator={validateOccurrenceDetail} + validator={validateOccurrence} + validateFields={validateOccurrenceFields} onWrite={onWrite} hasStagingMode /> From f59a32cf1fce7b71e699fb199507162ce520256d Mon Sep 17 00:00:00 2001 From: juhanikat Date: Thu, 17 Sep 2026 15:03:51 +0300 Subject: [PATCH 20/23] remove outdated e2e tests --- cypress/e2e/locality.cy.js | 35 ----------------------------------- 1 file changed, 35 deletions(-) diff --git a/cypress/e2e/locality.cy.js b/cypress/e2e/locality.cy.js index 6d0a1fd83..39d1d7c2d 100644 --- a/cypress/e2e/locality.cy.js +++ b/cypress/e2e/locality.cy.js @@ -411,10 +411,6 @@ describe('Creating a new locality', () => { cy.get('[data-value=""]').click() cy.get('[id=max_age-textfield]').should('have.value', '15.97') }) - it('and visiting the occurrence tabs shows the create new occurrence button as disabled', () => { - cy.visit('locality/new?tab=3') - cy.get('#create-occurrence-button').should('exist').should('be.disabled') - }) }) describe('Editing a locality', () => { @@ -497,37 +493,6 @@ describe('Editing a locality', () => { cy.visit(`/locality/20920?tab=5`) cy.contains('wet_screen') }) - - it('and creating a new occurrence opens a new tab', () => { - cy.loginWithSession('testSu') - cy.visit('locality/20920?tab=3', { - onBeforeLoad(win) { - cy.stub(win, 'open').as('windowOpen') - }, - }) - cy.get('#edit-button').should('exist').click() - cy.get('#create-occurrence-button').should('not.be.disabled').click() - cy.get('@windowOpen').should('be.called') - }) - - it('and removing existing occurrence rows, then clicking the refresh occurrences button does not make the rows reappear', () => { - cy.loginWithSession('testSu') - cy.visit('locality/21050?tab=3') - cy.contains('meneghinii').should('exist') - cy.get('#edit-button').should('exist').click() - - cy.get('[data-cy="table-row-85729"]').find('[data-testid=RemoveCircleOutlineIcon]').click() - cy.get('[data-cy="table-row-85729"]').find('[data-testid=AddCircleOutlineIcon]').should('exist') - cy.get('#refresh-occurrences-button').click() - cy.get('[data-cy="table-row-85729"]').find('[data-testid=AddCircleOutlineIcon]').should('exist') - cy.addReferenceAndSave() - cy.visit('locality/21050?tab=3') - cy.contains('meneghinii').should('not.exist') - }) - - it( - 'TODO: adding occurrence to it (in another window), then clicking the refresh occurrences button makes the new occurrence appear' - ) }) describe('Locality table filtering', () => { From 1d7a09a37e48243a329c414bc5d1d7f5efdbeb60 Mon Sep 17 00:00:00 2001 From: juhanikat Date: Thu, 17 Sep 2026 15:04:14 +0300 Subject: [PATCH 21/23] revert textfield component changes --- .../DetailView/common/editingComponents.tsx | 35 +++++++++++++------ 1 file changed, 25 insertions(+), 10 deletions(-) diff --git a/frontend/src/components/DetailView/common/editingComponents.tsx b/frontend/src/components/DetailView/common/editingComponents.tsx index 9fcf5198c..6ad5303db 100755 --- a/frontend/src/components/DetailView/common/editingComponents.tsx +++ b/frontend/src/components/DetailView/common/editingComponents.tsx @@ -313,6 +313,7 @@ export const EditableTextField = (props: EditableTextFieldProp const value = event?.currentTarget?.value if (type === 'number') { const setNumberValue = handleSetEditData as EditableTextFieldNumberProps['handleSetEditData'] + const allowNegative = min === undefined || min < 0 if (value === '') { setNumberInputValue('') if (setNumberValue) { @@ -325,12 +326,24 @@ export const EditableTextField = (props: EditableTextFieldProp return } + if (integerOnly) { + if (!isAllowedIntegerInputValue(value, allowNegative)) return + } else { + if (!isAllowedNumberInputValue(value)) return + } + setNumberInputValue(value) + const asNumber = Number(value) + if ( + (integerOnly ? isPartialIntegerInputValue(value, allowNegative) : isPartialNumberInputValue(value)) || + Number.isNaN(asNumber) + ) + return - if (setNumberValue && !Number.isNaN(asNumber)) { + if (setNumberValue) { setNumberValue(asNumber) } else { - const nextEditData = { ...editData, [field]: value } + const nextEditData = { ...editData, [field]: asNumber } setEditData(nextEditData) updateFieldErrors(nextEditData) } @@ -368,16 +381,12 @@ export const EditableTextField = (props: EditableTextFieldProp const setNumberValue = handleSetEditData as EditableTextFieldNumberProps['handleSetEditData'] if (value === '') return const allowNegative = min === undefined || min < 0 - const invalidIntegerValue = integerOnly && !isAllowedIntegerInputValue(value, allowNegative) - const invalidDecimalValue = !integerOnly && !isAllowedNumberInputValue(value) - - if (invalidIntegerValue || invalidDecimalValue) { + if (integerOnly ? isPartialIntegerInputValue(value, allowNegative) : isPartialNumberInputValue(value)) { setNumberInputValue('') if (setNumberValue) setNumberValue('') else setEditData({ ...editData, [field]: '' }) return } - const asNumber = Number(value) if (Number.isNaN(asNumber)) return setNumberInputValue(String(asNumber)) @@ -387,19 +396,25 @@ export const EditableTextField = (props: EditableTextFieldProp { + if (type !== 'number') return + if (event.key === 'e' || event.key === 'E' || event.key === '+') event.preventDefault() + if (integerOnly && event.key === '.') event.preventDefault() + }} id={`${String(field)}-textfield`} - value={editData[field] ?? ''} + value={type === 'number' ? numberInputValue : editData[field] ?? ''} variant="outlined" size="small" error={!!error} helperText={error ?? ''} + type={type} multiline={big} disabled={disabled} onBlur={() => { if (type === 'number') handleNumberBlur() if (trim) trimValue() }} - slotProps={{ input: { readOnly: readonly ? true : false } }} + InputProps={readonly ? { readOnly: true } : { readOnly: false }} /> ) @@ -758,4 +773,4 @@ export const BasisForAgeSelection = ({ return ( field={targetField} EditElement={editingComponent} displayValue={displayValue} /> ) -} +} \ No newline at end of file From 36aee3a7438deb866e30877373ae6cb066b0ea0e Mon Sep 17 00:00:00 2001 From: juhanikat Date: Thu, 17 Sep 2026 15:04:55 +0300 Subject: [PATCH 22/23] occurrence creation through locality -> Occurrence tab works --- .../Locality/Tabs/OccurrencesTab.tsx | 87 ++++++++++++++----- .../Occurrence/OccurrenceDetails.tsx | 5 +- 2 files changed, 70 insertions(+), 22 deletions(-) diff --git a/frontend/src/components/Locality/Tabs/OccurrencesTab.tsx b/frontend/src/components/Locality/Tabs/OccurrencesTab.tsx index 031fe44cd..41df0ded9 100644 --- a/frontend/src/components/Locality/Tabs/OccurrencesTab.tsx +++ b/frontend/src/components/Locality/Tabs/OccurrencesTab.tsx @@ -28,6 +28,8 @@ import { LocalitySpeciesDetailsType, LocalitySpecies, OccurrenceDetailsType, + EditableOccurrenceData, + SpeciesDetailsType, } from '@/shared/types' import { calculateNormalizedMesowearScore } from '@/shared/utils/mesowear' import { validateOccurrence, validateOccurrenceFields } from '@/shared/validators/occurrence' @@ -55,13 +57,45 @@ const hasMesowearScoreInputs = (row: LocalitySpecies) => { ) } +const occurrenceFields: Array = [ + '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', +] + const NewOccurrenceDialogContent = ({ onSave, onClose, localityData, validateOccurrenceFields, }: { - onSave: (occurrence: EditDataType) => void + onSave: (occurrenceSpecificFields: EditDataType, comSpecies: SpeciesDetailsType) => void onClose: () => void localityData: EditDataType | undefined validateOccurrenceFields: (editData: EditDataType) => ValidationObject[] @@ -87,14 +121,31 @@ const NewOccurrenceDialogContent = ({ } try { - notify('Saved occurrence successfully.') - const occurrenceToSave: EditDataType = { - ...editData, - lid: editData.lid ?? localityData?.lid ?? 0, - loc_name: editData.loc_name ?? localityData?.loc_name ?? '', + const occurrenceSpecificFields = occurrenceFields.reduce>((data, field) => { + if (field in editData) data[field] = editData[field] + return data + }, {}) + + const comSpecies = { + now_ls: [], + com_taxa_synonym: [], + now_sau: [], + species_id: editData.species_id ?? undefined, + class_name: undefined, + subclass_or_superorder_name: undefined, + order_name: undefined, + suborder_or_superfamily_name: undefined, + family_name: editData.family_name ?? undefined, + subfamily_name: undefined, + genus_name: editData.genus_name ?? undefined, + species_name: editData.species_name ?? undefined, + unique_identifier: editData.unique_identifier ?? undefined, + taxonomic_status: undefined, + sp_comment: undefined, + sp_author: undefined, } - onSave(occurrenceToSave) + onSave(occurrenceSpecificFields, comSpecies) onClose() } catch (e) { notify('something went wrong', 'error') @@ -401,24 +452,18 @@ export const OccurrencesTab = () => { ) => { + onSave={( + occurrenceSpesificFields: EditDataType, + comSpecies: SpeciesDetailsType + ) => { const appendedOccurrence = { - ...newOccurrence, + ...occurrenceSpesificFields, lid: editData.lid, - species_id: newOccurrence.species_id ?? 0, + species_id: comSpecies.species_id ?? undefined, + com_species: comSpecies, rowState: 'new', - com_species: { - com_taxa_synonym: [], - now_sau: [], - species_id: newOccurrence.species_id ?? 0, - family_name: newOccurrence.family_name ?? null, - genus_name: newOccurrence.genus_name ?? null, - species_name: newOccurrence.species_name ?? null, - unique_identifier: newOccurrence.unique_identifier ?? null, - now_ls: [], - }, } as unknown as LocalitySpeciesDetailsType - + console.log(appendedOccurrence) setEditData({ ...editData, now_ls: [...editData.now_ls, appendedOccurrence], diff --git a/frontend/src/components/Occurrence/OccurrenceDetails.tsx b/frontend/src/components/Occurrence/OccurrenceDetails.tsx index dd6dc3458..cafc7ec00 100644 --- a/frontend/src/components/Occurrence/OccurrenceDetails.tsx +++ b/frontend/src/components/Occurrence/OccurrenceDetails.tsx @@ -206,7 +206,10 @@ export const OccurrenceDetails = () => { } const tabs: TabType[] = [ - { title: 'Occurrence', content: }, + { + title: 'Occurrence', + content: , + }, { title: 'Wear', content: }, { title: 'Isotopes', content: }, { From 763fe331c48163a2b9d82cd8faf19334550f7b00 Mon Sep 17 00:00:00 2001 From: juhanikat Date: Thu, 17 Sep 2026 15:50:12 +0300 Subject: [PATCH 23/23] show occurrence creation button in new localitieis too --- frontend/src/components/Locality/Tabs/OccurrencesTab.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/components/Locality/Tabs/OccurrencesTab.tsx b/frontend/src/components/Locality/Tabs/OccurrencesTab.tsx index 41df0ded9..5354e97c3 100644 --- a/frontend/src/components/Locality/Tabs/OccurrencesTab.tsx +++ b/frontend/src/components/Locality/Tabs/OccurrencesTab.tsx @@ -429,7 +429,7 @@ export const OccurrencesTab = () => { return ( - {!mode.read && !mode.new && ( + {!mode.read && ( {({ close }) => (