From 0b07fca3524ad24a6de05dac1a8b28baafc2ca09 Mon Sep 17 00:00:00 2001 From: juhanikat Date: Thu, 17 Sep 2026 14:04:43 +0300 Subject: [PATCH] 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 04f2ddf3..9fcf5198 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 - } + const invalidIntegerValue = integerOnly && !isAllowedIntegerInputValue(value, allowNegative) + const invalidDecimalValue = !integerOnly && !isAllowedNumberInputValue(value) - if (integerOnly && !isAllowedIntegerInputValue(value, allowNegative)) { - setNumberInputValue('') - if (setNumberValue) setNumberValue('') - else setEditData({ ...editData, [field]: '' }) - return - } - - 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 b534b564..031fe44c 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 4525ec23..b8f9fa82 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 ? [ '',