From 876e34c1c8831349037eaec95f43d6b5a63ec6e1 Mon Sep 17 00:00:00 2001 From: jeznorth Date: Tue, 5 Sep 2023 12:55:17 -0700 Subject: [PATCH 01/62] Initial Prototype Development --- app/package.json | 1 + app/src/AppRouter.tsx | 6 + app/src/pages/prototype/PrototypePage.tsx | 228 ++++++++++++++++++++++ 3 files changed, 235 insertions(+) create mode 100644 app/src/pages/prototype/PrototypePage.tsx diff --git a/app/package.json b/app/package.json index 31a9857ba1..01a7e326f1 100644 --- a/app/package.json +++ b/app/package.json @@ -37,6 +37,7 @@ "@mui/styles": "^5.9.3", "@mui/system": "^5.12.3", "@mui/x-data-grid": "^6.3.1", + "@mui/x-data-grid-pro": "^6.12.1", "@mui/x-date-pickers": "^6.11.0", "@react-keycloak/web": "^3.4.0", "@react-leaflet/core": "~1.0.2", diff --git a/app/src/AppRouter.tsx b/app/src/AppRouter.tsx index 7381b074b4..87d50c2e80 100644 --- a/app/src/AppRouter.tsx +++ b/app/src/AppRouter.tsx @@ -23,6 +23,7 @@ import React from 'react'; import { Redirect, Route, Switch, useLocation } from 'react-router-dom'; import RouteWithTitle from 'utils/RouteWithTitle'; import { getTitle } from 'utils/Utils'; +import { PrototypePage } from 'pages/prototype/PrototypePage'; const AppRouter: React.FC = () => { const location = useLocation(); @@ -115,6 +116,10 @@ const AppRouter: React.FC = () => { + + + + @@ -136,6 +141,7 @@ const AppRouter: React.FC = () => { + ); }; diff --git a/app/src/pages/prototype/PrototypePage.tsx b/app/src/pages/prototype/PrototypePage.tsx new file mode 100644 index 0000000000..67ca3f0159 --- /dev/null +++ b/app/src/pages/prototype/PrototypePage.tsx @@ -0,0 +1,228 @@ +import Box from '@mui/material/Box'; +import Button from '@mui/material/Button'; +import IconButton from '@mui/material/IconButton'; +import AppBar from '@mui/material/AppBar'; +import Toolbar from '@mui/material/Toolbar'; +import { DataGridPro } from '@mui/x-data-grid-pro/DataGridPro'; +import Typography from '@mui/material/Typography'; +import { mdiTrashCanOutline } from '@mdi/js'; +import Icon from '@mdi/react'; + +const columns = [ + { + field: 'speciesName', + headerName: 'Species', + editable: true, + flex: 1, + minWidth: 250, + disableColumnMenu: true + }, + { + field: 'samplingSite', + headerName: 'Sampling Site', + editable: true, + type: 'singleSelect', + valueOptions: ['Site 1', 'Site 2', 'Site 3', 'Site 4'], + flex: 1, + minWidth: 180, + disableColumnMenu: true + }, + { + field: 'samplingMethod', + headerName: 'Sampling Method', + editable: true, + type: 'singleSelect', + valueOptions: ['Method 1', 'Method 2', 'Method 3', 'Method 4'], + flex: 1, + minWidth: 250, + disableColumnMenu: true, + + }, + { + field: 'count', + headerName: 'Count', + editable: true, + type: 'number', + flex: 0, + minWidth: 60, + disableColumnMenu: true, + }, + { + field: 'date', + headerName: 'Date', + editable: true, + type: 'date', + flex: 1, + minWidth: 100, + disableColumnMenu: true + }, + { + field: 'time', + headerName: 'Time', + editable: true, + type: 'time', + flex: 1, + minWidth: 100, + disableColumnMenu: true + }, + { + field: 'lat', + headerName: 'Lat', + editable: true, + flex: 1, + minWidth: 100, + disableColumnMenu: true + }, + { + field: 'long', + headerName: 'Long', + editable: true, + flex: 1, + minWidth: 100, + disableColumnMenu: true + }, + { + field: 'actions', + headerName: '', + type: 'actions', + width: 80, + disableColumnMenu: true, + resizable: false, + getActions: () => [ + + + + ], + } +]; + +const rows = [ + { id: 1, speciesName: 'Moose (Alces Americanus)' }, + { id: 2, speciesName: 'Moose (Alces Americanus)' }, + { id: 3, speciesName: 'Moose (Alces Americanus)' } +]; + +export default function RenderHeaderGrid() { + return ( +
+ +
+ ); +} + +export const PrototypePage = () => { + + return ( + + + + + Manage observations Prototype + + + + + + Sampling Sites + + + Content + + + + + + Observations + + + + + + + + + + + + + ); +}; From 84a67fbce776e2fa2352b5664a73a534cfafc04c Mon Sep 17 00:00:00 2001 From: jeznorth Date: Thu, 7 Sep 2023 10:25:43 -0700 Subject: [PATCH 02/62] UI Prototype Development - Adding Sampling Sites --- app/src/pages/prototype/PrototypePage.tsx | 263 +++++++++++++++++++--- 1 file changed, 231 insertions(+), 32 deletions(-) diff --git a/app/src/pages/prototype/PrototypePage.tsx b/app/src/pages/prototype/PrototypePage.tsx index 67ca3f0159..f6d690c182 100644 --- a/app/src/pages/prototype/PrototypePage.tsx +++ b/app/src/pages/prototype/PrototypePage.tsx @@ -3,10 +3,20 @@ import Button from '@mui/material/Button'; import IconButton from '@mui/material/IconButton'; import AppBar from '@mui/material/AppBar'; import Toolbar from '@mui/material/Toolbar'; -import { DataGridPro } from '@mui/x-data-grid-pro/DataGridPro'; +import { DataGrid } from '@mui/x-data-grid'; import Typography from '@mui/material/Typography'; -import { mdiTrashCanOutline } from '@mdi/js'; +import { mdiArrowLeft, mdiCogOutline, mdiDotsVertical, mdiImport, mdiPlus } from '@mdi/js'; import Icon from '@mdi/react'; +import { grey } from '@mui/material/colors'; +// import ListSubheader from '@mui/material/ListSubheader'; +import List from '@mui/material/List'; +import ListItem from '@mui/material/ListItem'; +// import ListItemIcon from '@mui/material/ListItemIcon'; +import ListItemText from '@mui/material/ListItemText'; +import * as React from 'react'; +import Accordion from '@mui/material/Accordion'; +import AccordionDetails from '@mui/material/AccordionDetails'; +import AccordionSummary from '@mui/material/AccordionSummary'; const columns = [ { @@ -24,7 +34,7 @@ const columns = [ type: 'singleSelect', valueOptions: ['Site 1', 'Site 2', 'Site 3', 'Site 4'], flex: 1, - minWidth: 180, + minWidth: 200, disableColumnMenu: true }, { @@ -34,17 +44,25 @@ const columns = [ type: 'singleSelect', valueOptions: ['Method 1', 'Method 2', 'Method 3', 'Method 4'], flex: 1, - minWidth: 250, - disableColumnMenu: true, - + minWidth: 200, + disableColumnMenu: true + }, + { + field: 'samplingPeriod', + headerName: 'Sampling Period', + editable: true, + type: 'singleSelect', + valueOptions: ['Period 1', 'Period 2', 'Period 3', 'Period 4', 'Undefined'], + flex: 1, + minWidth: 200, + disableColumnMenu: true }, { field: 'count', headerName: 'Count', editable: true, type: 'number', - flex: 0, - minWidth: 60, + minWidth: 100, disableColumnMenu: true, }, { @@ -52,33 +70,31 @@ const columns = [ headerName: 'Date', editable: true, type: 'date', - flex: 1, - minWidth: 100, - disableColumnMenu: true + minWidth: 150, + disableColumnMenu: true, }, { field: 'time', headerName: 'Time', editable: true, type: 'time', - flex: 1, - minWidth: 100, + width: 150, disableColumnMenu: true }, { field: 'lat', headerName: 'Lat', + type: 'number', editable: true, - flex: 1, - minWidth: 100, + width: 150, disableColumnMenu: true }, { field: 'long', headerName: 'Long', + type: 'number', editable: true, - flex: 1, - minWidth: 100, + width: 150, disableColumnMenu: true }, { @@ -88,20 +104,22 @@ const columns = [ width: 80, disableColumnMenu: true, resizable: false, + cellClassName: 'test', getActions: () => [ - + ], } ]; const rows = [ - { id: 1, speciesName: 'Moose (Alces Americanus)' }, - { id: 2, speciesName: 'Moose (Alces Americanus)' }, - { id: 3, speciesName: 'Moose (Alces Americanus)' } + { id: 1, speciesName: 'Moose (Alces Americanus', samplingSite: 'Site 1', samplingMethod: 'Method 1' }, + { id: 2, speciesName: 'Moose (Alces Americanus', samplingSite: 'Site 1', samplingMethod: 'Method 1' }, + { id: 3, speciesName: 'Moose (Alces Americanus', samplingSite: 'Site 1', samplingMethod: 'Method 1' } ]; + export default function RenderHeaderGrid() { return (
@@ -111,6 +129,13 @@ export default function RenderHeaderGrid() { } export const PrototypePage = () => { + + // const [expanded, setExpanded] = React.useState(false); + + // const handleChange = + // (panel: string) => (event: React.SyntheticEvent, isExpanded: boolean) => { + // setExpanded(isExpanded ? panel : false); + // }; return ( @@ -125,20 +150,30 @@ export const PrototypePage = () => { > - Manage observations Prototype + + + + Manage Observations Prototype + + {/* Sampling Site List */} { - Content + + + + + Sampling Site 1 + + + + + + + + + + Method 1 + + + + + + + YYYY-MM-DD to YYYY-MM-DD + + + + + YYYY-MM-DD to YYYY-MM-DD + + + + + + + + + + Sampling Site 1 + + + + + + + + + + Method 1 + + + + + + + YYYY-MM-DD to YYYY-MM-DD + + + + + YYYY-MM-DD to YYYY-MM-DD + + + + + + + + {/* Observations Component */} { { > Observations - + + + { overflow: 'hidden', }} > - { fontWeight: 700, textTransform: 'uppercase', color: '#999' + }, + '& .test': { + position: 'sticky', + right: 0, + top: 0, + borderLeft: '1px solid #ccc', + background: '#fff' + }, + '& .MuiDataGrid-columnHeaders': { + position: 'relative' + }, + '& .MuiDataGrid-columnHeaders:after': { + content: "''", + position: 'absolute', + right: 0, + width: '79px', + height: '80px', + borderLeft: '1px solid #ccc', + background: '#fff' } + }} /> From 53978d9cdb57d7f1978d5a0b7ccb7209bb6ebfc0 Mon Sep 17 00:00:00 2001 From: jeznorth Date: Fri, 8 Sep 2023 10:33:39 -0700 Subject: [PATCH 03/62] WIP --- app/src/pages/prototype/PrototypePage.tsx | 228 +++++++++++++++------- 1 file changed, 158 insertions(+), 70 deletions(-) diff --git a/app/src/pages/prototype/PrototypePage.tsx b/app/src/pages/prototype/PrototypePage.tsx index f6d690c182..507a2da034 100644 --- a/app/src/pages/prototype/PrototypePage.tsx +++ b/app/src/pages/prototype/PrototypePage.tsx @@ -1,11 +1,11 @@ import Box from '@mui/material/Box'; import Button from '@mui/material/Button'; import IconButton from '@mui/material/IconButton'; -import AppBar from '@mui/material/AppBar'; +// import AppBar from '@mui/material/AppBar'; import Toolbar from '@mui/material/Toolbar'; import { DataGrid } from '@mui/x-data-grid'; import Typography from '@mui/material/Typography'; -import { mdiArrowLeft, mdiCogOutline, mdiDotsVertical, mdiImport, mdiPlus } from '@mdi/js'; +import { mdiCogOutline, mdiDotsVertical, mdiImport, mdiPlus } from '@mdi/js'; import Icon from '@mdi/react'; import { grey } from '@mui/material/colors'; // import ListSubheader from '@mui/material/ListSubheader'; @@ -13,22 +13,25 @@ import List from '@mui/material/List'; import ListItem from '@mui/material/ListItem'; // import ListItemIcon from '@mui/material/ListItemIcon'; import ListItemText from '@mui/material/ListItemText'; -import * as React from 'react'; +// import * as React from 'react'; import Accordion from '@mui/material/Accordion'; import AccordionDetails from '@mui/material/AccordionDetails'; import AccordionSummary from '@mui/material/AccordionSummary'; +import Breadcrumbs from '@mui/material/Breadcrumbs'; +import Link from '@mui/material/Link'; +import Paper from '@mui/material/Paper'; const columns = [ - { - field: 'speciesName', + { + field: 'speciesName', headerName: 'Species', editable: true, flex: 1, minWidth: 250, disableColumnMenu: true }, - { - field: 'samplingSite', + { + field: 'samplingSite', headerName: 'Sampling Site', editable: true, type: 'singleSelect', @@ -37,8 +40,8 @@ const columns = [ minWidth: 200, disableColumnMenu: true }, - { - field: 'samplingMethod', + { + field: 'samplingMethod', headerName: 'Sampling Method', editable: true, type: 'singleSelect', @@ -47,8 +50,8 @@ const columns = [ minWidth: 200, disableColumnMenu: true }, - { - field: 'samplingPeriod', + { + field: 'samplingPeriod', headerName: 'Sampling Period', editable: true, type: 'singleSelect', @@ -57,47 +60,47 @@ const columns = [ minWidth: 200, disableColumnMenu: true }, - { - field: 'count', + { + field: 'count', headerName: 'Count', editable: true, type: 'number', minWidth: 100, disableColumnMenu: true, }, - { - field: 'date', + { + field: 'date', headerName: 'Date', editable: true, type: 'date', minWidth: 150, disableColumnMenu: true, }, - { - field: 'time', + { + field: 'time', headerName: 'Time', editable: true, type: 'time', width: 150, disableColumnMenu: true }, - { - field: 'lat', + { + field: 'lat', headerName: 'Lat', type: 'number', editable: true, width: 150, disableColumnMenu: true }, - { - field: 'long', + { + field: 'long', headerName: 'Long', type: 'number', editable: true, width: 150, disableColumnMenu: true }, - { + { field: 'actions', headerName: '', type: 'actions', @@ -113,11 +116,11 @@ const columns = [ } ]; -const rows = [ - { id: 1, speciesName: 'Moose (Alces Americanus', samplingSite: 'Site 1', samplingMethod: 'Method 1' }, - { id: 2, speciesName: 'Moose (Alces Americanus', samplingSite: 'Site 1', samplingMethod: 'Method 1' }, - { id: 3, speciesName: 'Moose (Alces Americanus', samplingSite: 'Site 1', samplingMethod: 'Method 1' } -]; +// const rows = [ +// { id: 1, speciesName: 'Moose (Alces Americanus', samplingSite: 'Site 1', samplingMethod: 'Method 1' }, +// { id: 2, speciesName: 'Moose (Alces Americanus', samplingSite: 'Site 1', samplingMethod: 'Method 1' }, +// { id: 3, speciesName: 'Moose (Alces Americanus', samplingSite: 'Site 1', samplingMethod: 'Method 1' } +// ]; export default function RenderHeaderGrid() { @@ -129,14 +132,6 @@ export default function RenderHeaderGrid() { } export const PrototypePage = () => { - - // const [expanded, setExpanded] = React.useState(false); - - // const handleChange = - // (panel: string) => (event: React.SyntheticEvent, isExpanded: boolean) => { - // setExpanded(isExpanded ? panel : false); - // }; - return ( { left: 0 }} > - + + + Survey Name + + + Manage Observations + + + + Manage Observations + + + + {/* { Manage Observations Prototype - - - */} + + {/* Sampling Site List */} - { borderBottom: '1px solid #ccc' }} > - Sampling Sites + + Sampling Sites + + { } }} > - - + No Sampling Sites + + + { p: 0 }} > - Sampling Site 1 + Sampling Site 1 { - { p: 0 }} > - Sampling Site 1 + Sampling Site 1 { {/* Observations Component */} - + { startIcon={ }> - Import Data + Import - + - + Map View + */} + + {/* Table View */} + + {/* + Records + */} + { }} /> + + + From 6ad724323fa715fe8fc7666171f0d3e7f0353a55 Mon Sep 17 00:00:00 2001 From: jeznorth Date: Fri, 8 Sep 2023 10:57:33 -0700 Subject: [PATCH 04/62] WIP --- app/src/pages/prototype/PrototypePage.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/src/pages/prototype/PrototypePage.tsx b/app/src/pages/prototype/PrototypePage.tsx index 507a2da034..f0aef5ca90 100644 --- a/app/src/pages/prototype/PrototypePage.tsx +++ b/app/src/pages/prototype/PrototypePage.tsx @@ -166,7 +166,7 @@ export const PrototypePage = () => { color="text.secondary" variant='body2' > - Manage Observations + Manage Survey Observations { ml: '-2px' }} > - Manage Observations + Manage Survey Observations From 88525d977bbe56b9378198f6ca220b7bb0ff12b7 Mon Sep 17 00:00:00 2001 From: Kjartan Date: Tue, 12 Sep 2023 14:52:13 -0700 Subject: [PATCH 05/62] split up prototype to new components --- app/src/AppRouter.tsx | 3 +- app/src/features/surveys/SurveyRouter.tsx | 7 + .../observations/ObservationComponent.tsx | 155 +++++++++++++++ .../observations/ObservationDataGrid.tsx | 60 ++++++ .../observations/ObservationMapView.tsx | 17 ++ .../observations/SurveyObservationHeader.tsx | 65 +++++++ .../observations/SurveyObservationPage.tsx | 29 +++ .../sampling-sites/SamplingSiteList.tsx | 176 ++++++++++++++++++ 8 files changed, 510 insertions(+), 2 deletions(-) create mode 100644 app/src/features/surveys/observations/ObservationComponent.tsx create mode 100644 app/src/features/surveys/observations/ObservationDataGrid.tsx create mode 100644 app/src/features/surveys/observations/ObservationMapView.tsx create mode 100644 app/src/features/surveys/observations/SurveyObservationHeader.tsx create mode 100644 app/src/features/surveys/observations/SurveyObservationPage.tsx create mode 100644 app/src/features/surveys/observations/sampling-sites/SamplingSiteList.tsx diff --git a/app/src/AppRouter.tsx b/app/src/AppRouter.tsx index 87d50c2e80..765ee61155 100644 --- a/app/src/AppRouter.tsx +++ b/app/src/AppRouter.tsx @@ -19,11 +19,11 @@ import LoginPage from 'pages/authentication/LoginPage'; import LogOutPage from 'pages/authentication/LogOutPage'; import { LandingPage } from 'pages/landing/LandingPage'; import { Playground } from 'pages/Playground'; +import { PrototypePage } from 'pages/prototype/PrototypePage'; import React from 'react'; import { Redirect, Route, Switch, useLocation } from 'react-router-dom'; import RouteWithTitle from 'utils/RouteWithTitle'; import { getTitle } from 'utils/Utils'; -import { PrototypePage } from 'pages/prototype/PrototypePage'; const AppRouter: React.FC = () => { const location = useLocation(); @@ -141,7 +141,6 @@ const AppRouter: React.FC = () => { - ); }; diff --git a/app/src/features/surveys/SurveyRouter.tsx b/app/src/features/surveys/SurveyRouter.tsx index 56727fa72c..83a1c51730 100644 --- a/app/src/features/surveys/SurveyRouter.tsx +++ b/app/src/features/surveys/SurveyRouter.tsx @@ -7,6 +7,7 @@ import { Redirect, Route, Switch } from 'react-router'; import RouteWithTitle from 'utils/RouteWithTitle'; import { getTitle } from 'utils/Utils'; import EditSurveyPage from './edit/EditSurveyPage'; +import { SurveyObservationPage } from './observations/SurveyObservationPage'; /** * Router for all `/admin/projects/:id/surveys/:survey_id/*` pages. @@ -28,6 +29,12 @@ const SurveyRouter: React.FC = () => { + + + + + + [ + + + + ] + } +]; + +export const ObservationComponent = () => { + return ( + + {/* Observations Component */} + + + Observations + + + + + + + {/* Map View */} + + {/* */} + + {/* Table View */} + + + + + ); +}; diff --git a/app/src/features/surveys/observations/ObservationDataGrid.tsx b/app/src/features/surveys/observations/ObservationDataGrid.tsx new file mode 100644 index 0000000000..8c53c88a08 --- /dev/null +++ b/app/src/features/surveys/observations/ObservationDataGrid.tsx @@ -0,0 +1,60 @@ +import Box from '@mui/material/Box'; +import { DataGrid } from '@mui/x-data-grid'; + +export interface IObservationDataGridProps { + columns: any[]; +} + +export const ObservationDataGrid: React.FC = (props) => { + return ( + + {/* + Records + */} + + + + + ); +}; diff --git a/app/src/features/surveys/observations/ObservationMapView.tsx b/app/src/features/surveys/observations/ObservationMapView.tsx new file mode 100644 index 0000000000..244ac7c6eb --- /dev/null +++ b/app/src/features/surveys/observations/ObservationMapView.tsx @@ -0,0 +1,17 @@ +import Box from '@mui/material/Box'; +import Typography from '@mui/material/Typography'; + +export const ObservationMapView = () => { + return ( + + Map View + + ); +}; diff --git a/app/src/features/surveys/observations/SurveyObservationHeader.tsx b/app/src/features/surveys/observations/SurveyObservationHeader.tsx new file mode 100644 index 0000000000..dc2e45eedd --- /dev/null +++ b/app/src/features/surveys/observations/SurveyObservationHeader.tsx @@ -0,0 +1,65 @@ +import Breadcrumbs from '@mui/material/Breadcrumbs'; +import Link from '@mui/material/Link'; +import Paper from '@mui/material/Paper'; +import Typography from '@mui/material/Typography'; + +export interface SurveyObservationHeaderProps { + surveyName: string; +} + +export const SurveyObservationHeader: React.FC = (props) => { + return ( + <> + + + + {props.surveyName} + + + Manage Survey Observations + + + + Manage Survey Observations + + + + {/* + + + + + Manage Observations Prototype + + */} + + ); +}; diff --git a/app/src/features/surveys/observations/SurveyObservationPage.tsx b/app/src/features/surveys/observations/SurveyObservationPage.tsx new file mode 100644 index 0000000000..5e61440181 --- /dev/null +++ b/app/src/features/surveys/observations/SurveyObservationPage.tsx @@ -0,0 +1,29 @@ +import Box from '@mui/material/Box'; +import CircularProgress from '@mui/material/CircularProgress'; +import { SurveyContext } from 'contexts/surveyContext'; +import { useContext } from 'react'; +import { ObservationComponent } from './ObservationComponent'; +import { SamplingSiteList } from './sampling-sites/SamplingSiteList'; +import { SurveyObservationHeader } from './SurveyObservationHeader'; + +export const SurveyObservationPage = () => { + const surveyContext = useContext(SurveyContext); + + if (!surveyContext.surveyDataLoader.data) { + return ; + } + + return ( + + + + + {/* Sampling Site List */} + + + {/* Observations Component */} + + + + ); +}; diff --git a/app/src/features/surveys/observations/sampling-sites/SamplingSiteList.tsx b/app/src/features/surveys/observations/sampling-sites/SamplingSiteList.tsx new file mode 100644 index 0000000000..3abb4924a6 --- /dev/null +++ b/app/src/features/surveys/observations/sampling-sites/SamplingSiteList.tsx @@ -0,0 +1,176 @@ +import { mdiDotsVertical, mdiPlus } from '@mdi/js'; +import Icon from '@mdi/react'; +// import * as React from 'react'; +import Accordion from '@mui/material/Accordion'; +import AccordionDetails from '@mui/material/AccordionDetails'; +import AccordionSummary from '@mui/material/AccordionSummary'; +import Box from '@mui/material/Box'; +import Button from '@mui/material/Button'; +import { grey } from '@mui/material/colors'; +import IconButton from '@mui/material/IconButton'; +// import ListSubheader from '@mui/material/ListSubheader'; +import List from '@mui/material/List'; +import ListItem from '@mui/material/ListItem'; +// import ListItemIcon from '@mui/material/ListItemIcon'; +import ListItemText from '@mui/material/ListItemText'; +// import AppBar from '@mui/material/AppBar'; +import Toolbar from '@mui/material/Toolbar'; +import Typography from '@mui/material/Typography'; + +export const SamplingSiteList = () => { + return ( + + + + Sampling Sites + + + + + + No Sampling Sites + + + + + + + Sampling Site 1 + + + + + + + + + + + Method 1 + + + + + + + YYYY-MM-DD to YYYY-MM-DD + + + + + YYYY-MM-DD to YYYY-MM-DD + + + + + + + + + + + Sampling Site 1 + + + + + + + + + + + Method 1 + + + + + + + YYYY-MM-DD to YYYY-MM-DD + + + + + YYYY-MM-DD to YYYY-MM-DD + + + + + + + + ); +}; From 195db7b38eb8410c02e2ed8a95bece5c740e52eb Mon Sep 17 00:00:00 2001 From: Curtis Upshall Date: Fri, 15 Sep 2023 12:58:30 -0700 Subject: [PATCH 06/62] SIMSBIOHUB-223: Create Observations table component --- app/src/pages/prototype/ObservationsTable.tsx | 226 ++++++++++++++++++ app/src/pages/prototype/PrototypePage.tsx | 177 +++----------- 2 files changed, 261 insertions(+), 142 deletions(-) create mode 100644 app/src/pages/prototype/ObservationsTable.tsx diff --git a/app/src/pages/prototype/ObservationsTable.tsx b/app/src/pages/prototype/ObservationsTable.tsx new file mode 100644 index 0000000000..e98b6b426b --- /dev/null +++ b/app/src/pages/prototype/ObservationsTable.tsx @@ -0,0 +1,226 @@ +import { mdiDotsVertical } from "@mdi/js"; +import Icon from "@mdi/react"; +import { Theme } from "@mui/material"; +import IconButton from '@mui/material/IconButton'; +import { makeStyles } from "@mui/styles"; +import { DataGrid, GridColDef } from '@mui/x-data-grid'; + +export interface IObservationRecord { + observation_id: number; + speciesName: string; + samplingSite: string; + samplingMethod: string; + samplingPeriod: string; + count?: number; + date?: string; + time?: string; + lat?: number; + long?: number; +} + +export interface IObservationTableRow extends Omit { + id: string; + observation_id: number | null; + isModified: boolean; +} + +export type IObservationsTableProps = { + observations: IObservationTableRow[] + onChangeObservations: (observations: IObservationTableRow[]) => void +} + +const useStyles = makeStyles((theme: Theme) => ({ + modifiedRow: {} // { background: 'rgba(65, 168, 3, 0.16)' } +})); + +export const observationColumns: GridColDef[] = [ + { + field: 'speciesName', + headerName: 'Species', + editable: true, + flex: 1, + minWidth: 250, + disableColumnMenu: true + }, + { + field: 'samplingSite', + headerName: 'Sampling Site', + editable: true, + type: 'singleSelect', + valueOptions: ['Site 1', 'Site 2', 'Site 3', 'Site 4'], + flex: 1, + minWidth: 200, + disableColumnMenu: true + }, + { + field: 'samplingMethod', + headerName: 'Sampling Method', + editable: true, + type: 'singleSelect', + valueOptions: ['Method 1', 'Method 2', 'Method 3', 'Method 4'], + flex: 1, + minWidth: 200, + disableColumnMenu: true + }, + { + field: 'samplingPeriod', + headerName: 'Sampling Period', + editable: true, + type: 'singleSelect', + valueOptions: ['Period 1', 'Period 2', 'Period 3', 'Period 4', 'Undefined'], + flex: 1, + minWidth: 200, + disableColumnMenu: true + }, + { + field: 'count', + headerName: 'Count', + editable: true, + type: 'number', + minWidth: 100, + disableColumnMenu: true, + }, + { + field: 'date', + headerName: 'Date', + editable: true, + type: 'date', + minWidth: 150, + disableColumnMenu: true, + }, + { + field: 'time', + headerName: 'Time', + editable: true, + type: 'time', + width: 150, + disableColumnMenu: true + }, + { + field: 'lat', + headerName: 'Lat', + type: 'number', + editable: true, + width: 150, + disableColumnMenu: true + }, + { + field: 'long', + headerName: 'Long', + type: 'number', + editable: true, + width: 150, + disableColumnMenu: true + }, + { + field: 'actions', + headerName: '', + type: 'actions', + width: 80, + disableColumnMenu: true, + resizable: false, + cellClassName: 'test', + getActions: () => [ + + + + ], + }, + { + field: 'isModified', + type: 'boolean' + } +] + +export const fetchObservationDemoRows = async (): Promise => { + await setTimeout(() => {}, 100 * (Math.random() + 1)); + return [ + { + observation_id: 1, + speciesName: 'Moose (Alces Americanus)', + samplingSite: 'Site 1', + samplingMethod: 'Method 1', + samplingPeriod: '', + }, + { + observation_id: 2, + speciesName: 'Moose (Alces Americanus)', + samplingSite: 'Site 1', + samplingMethod: 'Method 1', + samplingPeriod: '', + }, + { + observation_id: 3, + speciesName: 'Moose (Alces Americanus)', + samplingSite: 'Site 1', + samplingMethod: 'Method 1', + samplingPeriod: '', + } + ] +} + +const ObservationsTable = (props: IObservationsTableProps) => { + const classes = useStyles(); + + // const handleChangeState = (params: any) => { + // // props.onChangeObservations(Object.values(params.rows.dataRowIdToModelLookup)); + // } + + const numModified = props.observations.filter((row) => row.isModified).length; + + return ( + console.log('Edit stop', params)} + onRowEditStart={(params) => console.log('Edit start', params)} + onRowEditCommit={(params) => console.log('Edit commit', params)} + processRowUpdate={(newRow, oldRow) => ({ ...newRow, isModified: true })} + columns={observationColumns} + rows={props.observations} + localeText={{ + noRowsLabel: "No Records", + footerRowSelected: (numSelected: number) => { + return [ + numSelected > 0 && `${numSelected} rows selected`, + numModified > 0 && `${numModified} unsaved changes` + ].filter(Boolean).join(', ') + } + }} + getRowClassName={(params) => params.row.isModified ? classes.modifiedRow : ''} + + // onStateChange={handleChangeState} + sx={{ + background: '#fff', + border: 'none', + '& .MuiDataGrid-pinnedColumns, .MuiDataGrid-pinnedColumnHeaders': { + background: '#fff' + }, + '& .MuiDataGrid-columnHeaderTitle': { + fontWeight: 700, + textTransform: 'uppercase', + color: '#999' + }, + '& .test': { + position: 'sticky', + right: 0, + top: 0, + borderLeft: '1px solid #ccc', + background: '#fff' + }, + '& .MuiDataGrid-columnHeaders': { + position: 'relative' + }, + '& .MuiDataGrid-columnHeaders:after': { + content: "''", + position: 'absolute', + right: 0, + width: '79px', + height: '80px', + borderLeft: '1px solid #ccc', + background: '#fff' + } + }} + /> + ) +} + +export default ObservationsTable; diff --git a/app/src/pages/prototype/PrototypePage.tsx b/app/src/pages/prototype/PrototypePage.tsx index f0aef5ca90..1cab3120b6 100644 --- a/app/src/pages/prototype/PrototypePage.tsx +++ b/app/src/pages/prototype/PrototypePage.tsx @@ -3,7 +3,6 @@ import Button from '@mui/material/Button'; import IconButton from '@mui/material/IconButton'; // import AppBar from '@mui/material/AppBar'; import Toolbar from '@mui/material/Toolbar'; -import { DataGrid } from '@mui/x-data-grid'; import Typography from '@mui/material/Typography'; import { mdiCogOutline, mdiDotsVertical, mdiImport, mdiPlus } from '@mdi/js'; import Icon from '@mdi/react'; @@ -20,108 +19,9 @@ import AccordionSummary from '@mui/material/AccordionSummary'; import Breadcrumbs from '@mui/material/Breadcrumbs'; import Link from '@mui/material/Link'; import Paper from '@mui/material/Paper'; - -const columns = [ - { - field: 'speciesName', - headerName: 'Species', - editable: true, - flex: 1, - minWidth: 250, - disableColumnMenu: true - }, - { - field: 'samplingSite', - headerName: 'Sampling Site', - editable: true, - type: 'singleSelect', - valueOptions: ['Site 1', 'Site 2', 'Site 3', 'Site 4'], - flex: 1, - minWidth: 200, - disableColumnMenu: true - }, - { - field: 'samplingMethod', - headerName: 'Sampling Method', - editable: true, - type: 'singleSelect', - valueOptions: ['Method 1', 'Method 2', 'Method 3', 'Method 4'], - flex: 1, - minWidth: 200, - disableColumnMenu: true - }, - { - field: 'samplingPeriod', - headerName: 'Sampling Period', - editable: true, - type: 'singleSelect', - valueOptions: ['Period 1', 'Period 2', 'Period 3', 'Period 4', 'Undefined'], - flex: 1, - minWidth: 200, - disableColumnMenu: true - }, - { - field: 'count', - headerName: 'Count', - editable: true, - type: 'number', - minWidth: 100, - disableColumnMenu: true, - }, - { - field: 'date', - headerName: 'Date', - editable: true, - type: 'date', - minWidth: 150, - disableColumnMenu: true, - }, - { - field: 'time', - headerName: 'Time', - editable: true, - type: 'time', - width: 150, - disableColumnMenu: true - }, - { - field: 'lat', - headerName: 'Lat', - type: 'number', - editable: true, - width: 150, - disableColumnMenu: true - }, - { - field: 'long', - headerName: 'Long', - type: 'number', - editable: true, - width: 150, - disableColumnMenu: true - }, - { - field: 'actions', - headerName: '', - type: 'actions', - width: 80, - disableColumnMenu: true, - resizable: false, - cellClassName: 'test', - getActions: () => [ - - - - ], - } -]; - -// const rows = [ -// { id: 1, speciesName: 'Moose (Alces Americanus', samplingSite: 'Site 1', samplingMethod: 'Method 1' }, -// { id: 2, speciesName: 'Moose (Alces Americanus', samplingSite: 'Site 1', samplingMethod: 'Method 1' }, -// { id: 3, speciesName: 'Moose (Alces Americanus', samplingSite: 'Site 1', samplingMethod: 'Method 1' } -// ]; - +import ObservationsTable, { IObservationTableRow, fetchObservationDemoRows } from './ObservationsTable'; +import { useEffect, useState } from 'react'; +import { v4 as uuidv4 } from 'uuid'; export default function RenderHeaderGrid() { return ( @@ -132,6 +32,33 @@ export default function RenderHeaderGrid() { } export const PrototypePage = () => { + const [observationRows, setObservationRows] = useState([]); + + useEffect(() => { + fetchObservationDemoRows().then((rows) => { + setObservationRows(rows.map((row) => ({ + ...row, + id: String(row.observation_id), + isModified: false + }))) + }) + }, []); + + const createNewRecord = () => { + setObservationRows([ + ...observationRows, + { + id: uuidv4(), + observation_id: null, + isModified: true, + speciesName: '', + samplingSite: '', + samplingMethod: '', + samplingPeriod: '', + } + ]) + } + return ( { - + -
- ); -} - -export const PrototypePage = () => { - const observationsContext = useContext(ObservationsContext) - /* - useEffect(() => { - fetchObservationDemoRows().then((rows) => { - setRows(rows.map((row) => ({ - ...row, - id: String(row.observation_id), - isModified: false - }))) - }) - }, []); - */ - - /* - const createNewRecord = () => { - setObservationRows([ - ...observationRows, - { - id: uuidv4(), - observation_id: null, - isModified: true, - speciesName: '', - samplingSite: '', - samplingMethod: '', - samplingPeriod: '', - } - ]) - } - */ - - return ( - - - - - - Survey Name - - - Manage Survey Observations - - - - Manage Survey Observations - - - - {/* - - - - - Manage Observations Prototype - - */} - - - {/* Sampling Site List */} - - - - Sampling Sites - - - - - - No Sampling Sites - - - - - - Sampling Site 1 - - - - - - - - - - Method 1 - - - - - - - YYYY-MM-DD to YYYY-MM-DD - - - - - YYYY-MM-DD to YYYY-MM-DD - - - - - - - - - - Sampling Site 1 - - - - - - - - - - Method 1 - - - - - - - YYYY-MM-DD to YYYY-MM-DD - - - - - YYYY-MM-DD to YYYY-MM-DD - - - - - - - - - - {/* Observations Component */} - - - - - Observations - - - - - - - - {/* Map View */} - - {/* - Map View - */} - - {/* Table View */} - - {/* - Records - */} - - - - - - - - - - - - ); -}; From 46b75dd93f09b6d4c902c1f0f28d743063d6f601 Mon Sep 17 00:00:00 2001 From: Curtis Upshall Date: Wed, 20 Sep 2023 11:44:39 -0700 Subject: [PATCH 15/62] SIMSBIOHUB-223: More experimental changes --- app/src/contexts/observationsContext.tsx | 20 +- .../observations/ObservationComponent.tsx | 16 +- .../observations/ObservationsTable.tsx | 236 ++++++++++-------- 3 files changed, 165 insertions(+), 107 deletions(-) diff --git a/app/src/contexts/observationsContext.tsx b/app/src/contexts/observationsContext.tsx index 30b46e38e2..f69b1771d1 100644 --- a/app/src/contexts/observationsContext.tsx +++ b/app/src/contexts/observationsContext.tsx @@ -57,6 +57,7 @@ export const fetchObservationDemoRows = async (): Promise */ export type IObservationsContext = { createNewRecord: () => void; + _commitRows: () => void; _rows: IObservationTableRow[] _rowModesModel: GridRowModesModel; _setRows: Dispatch> // (newRows: (oldRows: GridRowsProp) => GridRowsProp) => void; @@ -68,6 +69,7 @@ export const ObservationsContext = createContext({ _rowModesModel: {}, _setRows: () => {}, _setRowModesModel: () => {}, + _commitRows: () => {}, createNewRecord: () => {}, }); @@ -92,7 +94,6 @@ export const ObservationsContextProvider = (props: PropsWithChildren { const id = uuidv4(); - _setRows((oldRows) => [ ...oldRows, { @@ -105,19 +106,32 @@ export const ObservationsContextProvider = (props: PropsWithChildren ({ ...oldModel, [id]: { mode: GridRowModes.Edit, fieldToFocus: 'speciesName' }, })); }; + const handleCommitRows = () => { + /* + _setRowModesModel((oldModel) => { + return Object.keys(oldModel) + .reduce((newModel: GridRowModesModel, key) => { + newModel[key] = { ...oldModel[key], mode: '' }; + + return newModel; + }, {}); + }) + */ + } + const observationsContext: IObservationsContext = { createNewRecord, _rows, _rowModesModel, _setRows, - _setRowModesModel + _setRowModesModel, + _commitRows: handleCommitRows }; return ( diff --git a/app/src/features/surveys/observations/ObservationComponent.tsx b/app/src/features/surveys/observations/ObservationComponent.tsx index 18d45e2ca9..d0732ff103 100644 --- a/app/src/features/surveys/observations/ObservationComponent.tsx +++ b/app/src/features/surveys/observations/ObservationComponent.tsx @@ -1,4 +1,4 @@ -import { mdiCogOutline, mdiImport, mdiPlus } from '@mdi/js'; +import { mdiCogOutline, mdiFloppy, mdiImport, mdiPlus, mdiTrashCan } from '@mdi/js'; import Icon from '@mdi/react'; import Box from '@mui/material/Box'; import Button from '@mui/material/Button'; @@ -9,7 +9,9 @@ import ObservationsTable from 'features/surveys/observations/ObservationsTable'; import { useContext } from 'react'; export const ObservationComponent = () => { - const observationsContext = useContext(ObservationsContext) + const observationsContext = useContext(ObservationsContext); + + const showSaveButton = true; return ( { }}> Observations + {showSaveButton && ( + <> + + + + )} diff --git a/app/src/features/surveys/observations/ObservationsTable.tsx b/app/src/features/surveys/observations/ObservationsTable.tsx index 72903757f8..7ef9bdb24e 100644 --- a/app/src/features/surveys/observations/ObservationsTable.tsx +++ b/app/src/features/surveys/observations/ObservationsTable.tsx @@ -1,9 +1,9 @@ -import { mdiDotsVertical } from "@mdi/js"; +import { mdiDotsVertical, mdiTrashCan } from "@mdi/js"; import Icon from "@mdi/react"; import { Theme } from "@mui/material"; import IconButton from '@mui/material/IconButton'; import { makeStyles } from "@mui/styles"; -import { DataGrid, GridColDef, GridEventListener } from '@mui/x-data-grid'; +import { DataGrid, GridColDef, GridEventListener, GridRowEditStopReasons, GridRowModes } from '@mui/x-data-grid'; import { IObservationTableRow, ObservationsContext } from "contexts/observationsContext"; import { useContext } from "react"; // import { useEffect, useState } from "react"; @@ -15,115 +15,129 @@ const useStyles = makeStyles((theme: Theme) => ({ modifiedRow: {} // { background: 'rgba(65, 168, 3, 0.16)' } })); -export const observationColumns: GridColDef[] = [ - { - field: 'speciesName', - headerName: 'Species', - editable: true, - flex: 1, - minWidth: 250, - disableColumnMenu: true - }, - { - field: 'samplingSite', - headerName: 'Sampling Site', - editable: true, - type: 'singleSelect', - valueOptions: ['Site 1', 'Site 2', 'Site 3', 'Site 4'], - flex: 1, - minWidth: 200, - disableColumnMenu: true - }, - { - field: 'samplingMethod', - headerName: 'Sampling Method', - editable: true, - type: 'singleSelect', - valueOptions: ['Method 1', 'Method 2', 'Method 3', 'Method 4'], - flex: 1, - minWidth: 200, - disableColumnMenu: true - }, - { - field: 'samplingPeriod', - headerName: 'Sampling Period', - editable: true, - type: 'singleSelect', - valueOptions: ['Period 1', 'Period 2', 'Period 3', 'Period 4', 'Undefined'], - flex: 1, - minWidth: 200, - disableColumnMenu: true - }, - { - field: 'count', - headerName: 'Count', - editable: true, - type: 'number', - minWidth: 100, - disableColumnMenu: true, - }, - { - field: 'date', - headerName: 'Date', - editable: true, - type: 'date', - minWidth: 150, - disableColumnMenu: true, - }, - { - field: 'time', - headerName: 'Time', - editable: true, - type: 'time', - width: 150, - disableColumnMenu: true - }, - { - field: 'lat', - headerName: 'Lat', - type: 'number', - editable: true, - width: 150, - disableColumnMenu: true - }, - { - field: 'long', - headerName: 'Long', - type: 'number', - editable: true, - width: 150, - disableColumnMenu: true - }, - { - field: 'actions', - headerName: '', - type: 'actions', - width: 80, - disableColumnMenu: true, - resizable: false, - cellClassName: 'test', - getActions: () => [ - - - - ], - } -]; - const ObservationsTable = (props: IObservationsTableProps) => { const classes = useStyles(); + + const observationColumns: GridColDef[] = [ + { + field: 'speciesName', + headerName: 'Species', + editable: true, + flex: 1, + minWidth: 250, + disableColumnMenu: true + }, + { + field: 'samplingSite', + headerName: 'Sampling Site', + editable: true, + type: 'singleSelect', + valueOptions: ['Site 1', 'Site 2', 'Site 3', 'Site 4'], + flex: 1, + minWidth: 200, + disableColumnMenu: true + }, + { + field: 'samplingMethod', + headerName: 'Sampling Method', + editable: true, + type: 'singleSelect', + valueOptions: ['Method 1', 'Method 2', 'Method 3', 'Method 4'], + flex: 1, + minWidth: 200, + disableColumnMenu: true + }, + { + field: 'samplingPeriod', + headerName: 'Sampling Period', + editable: true, + type: 'singleSelect', + valueOptions: ['Period 1', 'Period 2', 'Period 3', 'Period 4', 'Undefined'], + flex: 1, + minWidth: 200, + disableColumnMenu: true + }, + { + field: 'count', + headerName: 'Count', + editable: true, + type: 'number', + minWidth: 100, + disableColumnMenu: true, + }, + { + field: 'date', + headerName: 'Date', + editable: true, + type: 'date', + minWidth: 150, + disableColumnMenu: true, + }, + { + field: 'time', + headerName: 'Time', + editable: true, + type: 'time', + width: 150, + disableColumnMenu: true + }, + { + field: 'lat', + headerName: 'Lat', + type: 'number', + editable: true, + width: 150, + disableColumnMenu: true + }, + { + field: 'long', + headerName: 'Long', + type: 'number', + editable: true, + width: 150, + disableColumnMenu: true + }, + { + field: 'actions', + headerName: '', + type: 'actions', + width: 96, + disableColumnMenu: true, + resizable: false, + getActions: (params) => [ + ( + handleDeleteRow(params.id)}> + + + ), + ( + + + + ) + ], + } + ]; const { _rows, _setRows, _setRowModesModel, _rowModesModel } = useContext(ObservationsContext); - console.log('rowModesModel:', _rowModesModel); + + const handleDeleteRow = (id: string | number) => { + _setRows(_rows.filter((row) => row.id !== id)); + } + + // console.log('rowModesModel:', _rowModesModel); + /* const handleRowEditStart: GridEventListener<'rowEditStart'> = (params, event) => { console.log({ params }) } + */ const handleRowEditStop: GridEventListener<'rowEditStop'> = (params, event) => { event.defaultMuiPrevented = true; - //if (params.reason === GridRowEditStopReasons.rowFocusOut) { - // } + if (params.reason === GridRowEditStopReasons.rowFocusOut) { + // + } }; const handleProcessRowUpdate = (newRow: IObservationTableRow) => { @@ -133,12 +147,27 @@ const ObservationsTable = (props: IObservationsTableProps) => { return updatedRow; }; + /* + const handleStateChange: GridEventListener<'stateChange'> = (params, event) => { + // console.log('handleStateChange:', params); + } + */ + + const handleCellClick: GridEventListener<'cellClick'> = (params, event) => { + _setRowModesModel((oldModel) => ({ + ...oldModel, + [params.row.id]: { mode: GridRowModes.Edit, fieldToFocus: params.field } + })) + } + const numModified = _rows.filter((row) => row._isModified).length; return ( { content: "''", position: 'absolute', right: 0, - width: '79px', + width: '96px', height: '80px', borderLeft: '1px solid #ccc', background: '#fff' + }, + '& .MuiDataGrid-actionsCell': { + gap: 0 } }} /> From 29ca080b99d8e4e8c06b7c6b8ec9682bb9e52358 Mon Sep 17 00:00:00 2001 From: Curtis Upshall Date: Fri, 22 Sep 2023 09:33:32 -0700 Subject: [PATCH 16/62] SIMSBIOHUB-223: Use MUI datagrid API ref (experimental) --- app/src/contexts/observationsContext.tsx | 55 +++++-------------- .../observations/ObservationsTable.tsx | 52 ++++++++---------- 2 files changed, 36 insertions(+), 71 deletions(-) diff --git a/app/src/contexts/observationsContext.tsx b/app/src/contexts/observationsContext.tsx index f69b1771d1..d301a20118 100644 --- a/app/src/contexts/observationsContext.tsx +++ b/app/src/contexts/observationsContext.tsx @@ -1,6 +1,7 @@ -import { GridRowModes, GridRowModesModel } from '@mui/x-data-grid'; +import { useGridApiRef } from '@mui/x-data-grid'; +import { GridApiCommunity } from '@mui/x-data-grid/internals'; import useDataLoader from 'hooks/useDataLoader'; -import { createContext, Dispatch, PropsWithChildren, SetStateAction, useEffect, useState } from 'react'; +import { createContext, PropsWithChildren, useEffect } from 'react'; import { v4 as uuidv4 } from 'uuid'; export interface IObservationRecord { @@ -57,45 +58,36 @@ export const fetchObservationDemoRows = async (): Promise */ export type IObservationsContext = { createNewRecord: () => void; - _commitRows: () => void; - _rows: IObservationTableRow[] - _rowModesModel: GridRowModesModel; - _setRows: Dispatch> // (newRows: (oldRows: GridRowsProp) => GridRowsProp) => void; - _setRowModesModel: Dispatch> + _muiDataGridApiRef: React.MutableRefObject } export const ObservationsContext = createContext({ - _rows: [], - _rowModesModel: {}, - _setRows: () => {}, - _setRowModesModel: () => {}, - _commitRows: () => {}, + _muiDataGridApiRef: { current: null as unknown as GridApiCommunity }, createNewRecord: () => {}, - }); export const ObservationsContextProvider = (props: PropsWithChildren>) => { - const [_rows, _setRows] = useState([]); - const [_rowModesModel, _setRowModesModel] = useState({}); + const _muiDataGridApiRef = useGridApiRef(); const observationsDataLoader = useDataLoader(fetchObservationDemoRows); observationsDataLoader.load() useEffect(() => { - if (observationsDataLoader.data) { + if (observationsDataLoader.data && _muiDataGridApiRef.current.setRows) { const rows: IObservationTableRow[] = observationsDataLoader.data.map((row) => ({ ...row, id: String(row.observation_id), _isModified: false })); - _setRows(rows); + + _muiDataGridApiRef.current.setRows(rows); } }, [observationsDataLoader.data]) const createNewRecord = () => { const id = uuidv4(); - _setRows((oldRows) => [ - ...oldRows, + _muiDataGridApiRef.current.setRows([ + _muiDataGridApiRef.current.state.rows, { id, _isModified: true, @@ -106,32 +98,13 @@ export const ObservationsContextProvider = (props: PropsWithChildren ({ - ...oldModel, - [id]: { mode: GridRowModes.Edit, fieldToFocus: 'speciesName' }, - })); - }; - const handleCommitRows = () => { - /* - _setRowModesModel((oldModel) => { - return Object.keys(oldModel) - .reduce((newModel: GridRowModesModel, key) => { - newModel[key] = { ...oldModel[key], mode: '' }; - - return newModel; - }, {}); - }) - */ - } + _muiDataGridApiRef.current.startRowEditMode({ id, fieldToFocus: 'speciesName' }); + }; const observationsContext: IObservationsContext = { createNewRecord, - _rows, - _rowModesModel, - _setRows, - _setRowModesModel, - _commitRows: handleCommitRows + _muiDataGridApiRef }; return ( diff --git a/app/src/features/surveys/observations/ObservationsTable.tsx b/app/src/features/surveys/observations/ObservationsTable.tsx index 7ef9bdb24e..bf494d6d50 100644 --- a/app/src/features/surveys/observations/ObservationsTable.tsx +++ b/app/src/features/surveys/observations/ObservationsTable.tsx @@ -3,11 +3,11 @@ import Icon from "@mdi/react"; import { Theme } from "@mui/material"; import IconButton from '@mui/material/IconButton'; import { makeStyles } from "@mui/styles"; -import { DataGrid, GridColDef, GridEventListener, GridRowEditStopReasons, GridRowModes } from '@mui/x-data-grid'; +import { DataGrid, GridColDef, GridEventListener, GridRowEditStopReasons } from '@mui/x-data-grid'; import { IObservationTableRow, ObservationsContext } from "contexts/observationsContext"; import { useContext } from "react"; // import { useEffect, useState } from "react"; -import { pluralize as p } from "utils/Utils"; +// import { pluralize as p } from "utils/Utils"; export type IObservationsTableProps = Record; @@ -118,23 +118,16 @@ const ObservationsTable = (props: IObservationsTableProps) => { ], } ]; - - const { _rows, _setRows, _setRowModesModel, _rowModesModel } = useContext(ObservationsContext); + + const apiRef = useContext(ObservationsContext)._muiDataGridApiRef; const handleDeleteRow = (id: string | number) => { - _setRows(_rows.filter((row) => row.id !== id)); + apiRef.current.setRows(Object.values(apiRef.current.state.rows).filter((row) => row.id !== id)); } - // console.log('rowModesModel:', _rowModesModel); - - /* - const handleRowEditStart: GridEventListener<'rowEditStart'> = (params, event) => { - console.log({ params }) - } - */ - const handleRowEditStop: GridEventListener<'rowEditStop'> = (params, event) => { event.defaultMuiPrevented = true; + return; if (params.reason === GridRowEditStopReasons.rowFocusOut) { // } @@ -143,49 +136,48 @@ const ObservationsTable = (props: IObservationsTableProps) => { const handleProcessRowUpdate = (newRow: IObservationTableRow) => { const updatedRow: IObservationTableRow = { ...newRow, _isModified: true }; - _setRows(_rows.map((row) => (row.id === newRow.id ? updatedRow : row))); + apiRef.current.setRows(Object.values(apiRef.current.state.rows).map((row) => (row.id === newRow.id ? updatedRow : row))); return updatedRow; }; - /* - const handleStateChange: GridEventListener<'stateChange'> = (params, event) => { - // console.log('handleStateChange:', params); - } - */ - const handleCellClick: GridEventListener<'cellClick'> = (params, event) => { - _setRowModesModel((oldModel) => ({ - ...oldModel, - [params.row.id]: { mode: GridRowModes.Edit, fieldToFocus: params.field } - })) + apiRef.current.startRowEditMode({ id: params.row.id, fieldToFocus: params.field }); } - const numModified = _rows.filter((row) => row._isModified).length; + /* + const modifiedKeys = new Set([ + ...Object.keys(apiRef.current.state.editRows), + ...apiRef.current.get + ]); + */ + return ( { return [ numSelected > 0 && `${numSelected} ${p(numSelected, 'row')} selected`, numModified > 0 && `${numModified} unsaved ${p(numModified, 'row')}` ].filter(Boolean).join(', ') } + */ }} getRowClassName={(params) => { - if (params.row._isModified || _rowModesModel) { + if (params.row._isModified) { return classes.modifiedRow; } From b2f7e3974476cd1f29c6016cdd0b372e8cc226ab Mon Sep 17 00:00:00 2001 From: Curtis Upshall Date: Fri, 22 Sep 2023 10:02:45 -0700 Subject: [PATCH 17/62] SIMSBIOHUB-223: Changes --- app/src/contexts/observationsContext.tsx | 22 +++------------- .../observations/ObservationsTable.tsx | 25 ++++++++++++++++--- 2 files changed, 25 insertions(+), 22 deletions(-) diff --git a/app/src/contexts/observationsContext.tsx b/app/src/contexts/observationsContext.tsx index d301a20118..401345e5fd 100644 --- a/app/src/contexts/observationsContext.tsx +++ b/app/src/contexts/observationsContext.tsx @@ -1,7 +1,6 @@ import { useGridApiRef } from '@mui/x-data-grid'; import { GridApiCommunity } from '@mui/x-data-grid/internals'; -import useDataLoader from 'hooks/useDataLoader'; -import { createContext, PropsWithChildren, useEffect } from 'react'; +import { createContext, PropsWithChildren } from 'react'; import { v4 as uuidv4 } from 'uuid'; export interface IObservationRecord { @@ -69,25 +68,10 @@ export const ObservationsContext = createContext({ export const ObservationsContextProvider = (props: PropsWithChildren>) => { const _muiDataGridApiRef = useGridApiRef(); - const observationsDataLoader = useDataLoader(fetchObservationDemoRows); - observationsDataLoader.load() - - useEffect(() => { - if (observationsDataLoader.data && _muiDataGridApiRef.current.setRows) { - const rows: IObservationTableRow[] = observationsDataLoader.data.map((row) => ({ - ...row, - id: String(row.observation_id), - _isModified: false - })); - - _muiDataGridApiRef.current.setRows(rows); - } - }, [observationsDataLoader.data]) - const createNewRecord = () => { const id = uuidv4(); - _muiDataGridApiRef.current.setRows([ - _muiDataGridApiRef.current.state.rows, + + _muiDataGridApiRef.current.updateRows([ { id, _isModified: true, diff --git a/app/src/features/surveys/observations/ObservationsTable.tsx b/app/src/features/surveys/observations/ObservationsTable.tsx index bf494d6d50..901d115716 100644 --- a/app/src/features/surveys/observations/ObservationsTable.tsx +++ b/app/src/features/surveys/observations/ObservationsTable.tsx @@ -4,8 +4,9 @@ import { Theme } from "@mui/material"; import IconButton from '@mui/material/IconButton'; import { makeStyles } from "@mui/styles"; import { DataGrid, GridColDef, GridEventListener, GridRowEditStopReasons } from '@mui/x-data-grid'; -import { IObservationTableRow, ObservationsContext } from "contexts/observationsContext"; -import { useContext } from "react"; +import { IObservationTableRow, ObservationsContext, fetchObservationDemoRows } from "contexts/observationsContext"; +import useDataLoader from "hooks/useDataLoader"; +import { useContext, useEffect } from "react"; // import { useEffect, useState } from "react"; // import { pluralize as p } from "utils/Utils"; @@ -17,6 +18,8 @@ const useStyles = makeStyles((theme: Theme) => ({ const ObservationsTable = (props: IObservationsTableProps) => { const classes = useStyles(); + const observationsDataLoader = useDataLoader(fetchObservationDemoRows); + observationsDataLoader.load() const observationColumns: GridColDef[] = [ { @@ -120,13 +123,25 @@ const ObservationsTable = (props: IObservationsTableProps) => { ]; const apiRef = useContext(ObservationsContext)._muiDataGridApiRef; + + useEffect(() => { + if (observationsDataLoader.data) { + const rows: IObservationTableRow[] = observationsDataLoader.data.map((row) => ({ + ...row, + id: String(row.observation_id), + _isModified: false + })); + + apiRef.current.setRows(rows); + } + }, [observationsDataLoader.data]) const handleDeleteRow = (id: string | number) => { apiRef.current.setRows(Object.values(apiRef.current.state.rows).filter((row) => row.id !== id)); } const handleRowEditStop: GridEventListener<'rowEditStop'> = (params, event) => { - event.defaultMuiPrevented = true; + // event.defaultMuiPrevented = true; return; if (params.reason === GridRowEditStopReasons.rowFocusOut) { // @@ -141,6 +156,10 @@ const ObservationsTable = (props: IObservationsTableProps) => { }; const handleCellClick: GridEventListener<'cellClick'> = (params, event) => { + if (apiRef.current.state.editRows[params.row.id]) { + return; + } + apiRef.current.startRowEditMode({ id: params.row.id, fieldToFocus: params.field }); } From e62e9c9674c56c3f0c7f09492db7c38bf5deeab5 Mon Sep 17 00:00:00 2001 From: Curtis Upshall Date: Fri, 22 Sep 2023 11:11:22 -0700 Subject: [PATCH 18/62] SIMSBIOHUB-223: use updateRows method; updated data fetching --- app/src/contexts/observationsContext.tsx | 51 ++++++++++--------- .../observations/ObservationsTable.tsx | 14 ++--- 2 files changed, 35 insertions(+), 30 deletions(-) diff --git a/app/src/contexts/observationsContext.tsx b/app/src/contexts/observationsContext.tsx index 401345e5fd..bec01f9e14 100644 --- a/app/src/contexts/observationsContext.tsx +++ b/app/src/contexts/observationsContext.tsx @@ -23,30 +23,33 @@ export interface IObservationTableRow extends Omit => { - await setTimeout(() => {}, 100 * (Math.random() + 1)); - return [ - { - observation_id: 1, - speciesName: 'Moose (Alces Americanus)', - samplingSite: 'Site 1', - samplingMethod: 'Method 1', - samplingPeriod: '', - }, - { - observation_id: 2, - speciesName: 'Moose (Alces Americanus)', - samplingSite: 'Site 1', - samplingMethod: 'Method 1', - samplingPeriod: '', - }, - { - observation_id: 3, - speciesName: 'Moose (Alces Americanus)', - samplingSite: 'Site 1', - samplingMethod: 'Method 1', - samplingPeriod: '', - } - ] + return new Promise((resolve, reject) => { + setTimeout(() => { + resolve([ + { + observation_id: 1, + speciesName: 'Moose (Alces Americanus)', + samplingSite: 'Site 1', + samplingMethod: 'Method 1', + samplingPeriod: '', + }, + { + observation_id: 2, + speciesName: 'Moose (Alces Americanus)', + samplingSite: 'Site 1', + samplingMethod: 'Method 1', + samplingPeriod: '', + }, + { + observation_id: 3, + speciesName: 'Moose (Alces Americanus)', + samplingSite: 'Site 1', + samplingMethod: 'Method 1', + samplingPeriod: '', + } + ]) + }, 1000 * (Math.random() + 1)); + }) } /** diff --git a/app/src/features/surveys/observations/ObservationsTable.tsx b/app/src/features/surveys/observations/ObservationsTable.tsx index 901d115716..dd2da94790 100644 --- a/app/src/features/surveys/observations/ObservationsTable.tsx +++ b/app/src/features/surveys/observations/ObservationsTable.tsx @@ -6,7 +6,7 @@ import { makeStyles } from "@mui/styles"; import { DataGrid, GridColDef, GridEventListener, GridRowEditStopReasons } from '@mui/x-data-grid'; import { IObservationTableRow, ObservationsContext, fetchObservationDemoRows } from "contexts/observationsContext"; import useDataLoader from "hooks/useDataLoader"; -import { useContext, useEffect } from "react"; +import { useContext, useEffect, useState } from "react"; // import { useEffect, useState } from "react"; // import { pluralize as p } from "utils/Utils"; @@ -19,6 +19,8 @@ const useStyles = makeStyles((theme: Theme) => ({ const ObservationsTable = (props: IObservationsTableProps) => { const classes = useStyles(); const observationsDataLoader = useDataLoader(fetchObservationDemoRows); + const [initialRows, setInitialRows] = useState([]); + observationsDataLoader.load() const observationColumns: GridColDef[] = [ @@ -132,12 +134,12 @@ const ObservationsTable = (props: IObservationsTableProps) => { _isModified: false })); - apiRef.current.setRows(rows); + setInitialRows(rows); } }, [observationsDataLoader.data]) - + const handleDeleteRow = (id: string | number) => { - apiRef.current.setRows(Object.values(apiRef.current.state.rows).filter((row) => row.id !== id)); + apiRef.current.updateRows([{ id, _action: 'delete' }]) } const handleRowEditStop: GridEventListener<'rowEditStop'> = (params, event) => { @@ -151,7 +153,7 @@ const ObservationsTable = (props: IObservationsTableProps) => { const handleProcessRowUpdate = (newRow: IObservationTableRow) => { const updatedRow: IObservationTableRow = { ...newRow, _isModified: true }; - apiRef.current.setRows(Object.values(apiRef.current.state.rows).map((row) => (row.id === newRow.id ? updatedRow : row))); + // apiRef.current.setRows(Object.values(apiRef.current.state.rows).map((row) => (row.id === newRow.id ? updatedRow : row))); return updatedRow; }; @@ -180,7 +182,7 @@ const ObservationsTable = (props: IObservationsTableProps) => { onRowEditStop={handleRowEditStop} processRowUpdate={handleProcessRowUpdate} columns={observationColumns} - rows={[]} + rows={initialRows} // rowModesModel={_rowModesModel} disableRowSelectionOnClick // onRowModesModelChange={_setRowModesModel} From 783c6564aeccf6e67663ebd2c76e5d63a435a4d6 Mon Sep 17 00:00:00 2001 From: Curtis Upshall Date: Fri, 22 Sep 2023 16:10:52 -0700 Subject: [PATCH 19/62] SIMSBIOHUB-223: Save/Cancel behaviour --- app/src/contexts/observationsContext.tsx | 115 ++++++++++++++---- .../observations/ObservationComponent.tsx | 13 +- .../observations/ObservationsTable.tsx | 24 +--- 3 files changed, 110 insertions(+), 42 deletions(-) diff --git a/app/src/contexts/observationsContext.tsx b/app/src/contexts/observationsContext.tsx index bec01f9e14..eca25ceca0 100644 --- a/app/src/contexts/observationsContext.tsx +++ b/app/src/contexts/observationsContext.tsx @@ -4,22 +4,20 @@ import { createContext, PropsWithChildren } from 'react'; import { v4 as uuidv4 } from 'uuid'; export interface IObservationRecord { - observation_id: number; - speciesName: string; - samplingSite: string; - samplingMethod: string; - samplingPeriod: string; - count?: number; - date?: string; - time?: string; - lat?: number; - long?: number; + observation_id: number | undefined; + speciesName: string | undefined; + samplingSite: string | undefined; + samplingMethod: string | undefined; + samplingPeriod: string | undefined; + count: number | undefined; + date: string | undefined; + time: string | undefined; + lat: number | undefined; + long: number | undefined; } -export interface IObservationTableRow extends Omit { +export interface IObservationTableRow extends Partial { id: string; - observation_id: number | null; - _isModified: boolean; } export const fetchObservationDemoRows = async (): Promise => { @@ -31,21 +29,36 @@ export const fetchObservationDemoRows = async (): Promise speciesName: 'Moose (Alces Americanus)', samplingSite: 'Site 1', samplingMethod: 'Method 1', - samplingPeriod: '', + samplingPeriod: undefined, + count: undefined, + date: undefined, + time: undefined, + lat: undefined, + long: undefined }, { observation_id: 2, speciesName: 'Moose (Alces Americanus)', samplingSite: 'Site 1', samplingMethod: 'Method 1', - samplingPeriod: '', + samplingPeriod: undefined, + count: undefined, + date: undefined, + time: undefined, + lat: undefined, + long: undefined }, { observation_id: 3, speciesName: 'Moose (Alces Americanus)', samplingSite: 'Site 1', samplingMethod: 'Method 1', - samplingPeriod: '', + samplingPeriod: undefined, + count: undefined, + date: undefined, + time: undefined, + lat: undefined, + long: undefined } ]) }, 1000 * (Math.random() + 1)); @@ -60,37 +73,97 @@ export const fetchObservationDemoRows = async (): Promise */ export type IObservationsContext = { createNewRecord: () => void; + // getActiveRecords: () => IObservationTableRow[]; + saveRecords: () => Promise; + revertRecords: () => Promise; + refreshRecords: () => Promise; _muiDataGridApiRef: React.MutableRefObject } export const ObservationsContext = createContext({ _muiDataGridApiRef: { current: null as unknown as GridApiCommunity }, createNewRecord: () => {}, + // getActiveRecords: () => [], + revertRecords: () => Promise.resolve(), + saveRecords: () => Promise.resolve(), + refreshRecords: () => Promise.resolve() }); export const ObservationsContextProvider = (props: PropsWithChildren>) => { const _muiDataGridApiRef = useGridApiRef(); + /* + const _getRows = (): IObservationTableRow[] => { + return Array.from(_muiDataGridApiRef.current.getRowModels?.()?.values()) as IObservationTableRow[] + } + */ + const createNewRecord = () => { const id = uuidv4(); _muiDataGridApiRef.current.updateRows([ { id, - _isModified: true, observation_id: null, - speciesName: '', - samplingSite: '', - samplingMethod: '', - samplingPeriod: '', + speciesName: undefined, + samplingSite: undefined, + samplingMethod: undefined, + samplingPeriod: undefined, + count: undefined, + date: undefined, + time: undefined, + lat: undefined, + long: undefined } ]); _muiDataGridApiRef.current.startRowEditMode({ id, fieldToFocus: 'speciesName' }); }; + /* + const getActiveRecords = (): IObservationTableRow[] => { + return _getRows().map((row) => { + const editRow = _muiDataGridApiRef.current.state.editRows[row.id] + if (!editRow) { + return row; + } + + return Object + .entries(editRow) + .reduce((newRow, entry) => ({ ...row, ...newRow, _isModified: true, [entry[0]]: entry[1].value }), {}); + }) as IObservationTableRow[]; + } + */ + + const saveRecords = async () => { + const editingIds = Object.keys(_muiDataGridApiRef.current.state.editRows) + editingIds.forEach((id) => _muiDataGridApiRef.current.stopRowEditMode({ id })); + + /* + const rows = getActiveRecords() + .map((row) => ({ ...row, _isModified: false })) + + console.log(rows); + localStorage.setItem('__OBSERVATIONS_TEST', JSON.stringify(rows)) + return Promise.resolve() + */ + } + + const revertRecords = async () => { + const editingIds = Object.keys(_muiDataGridApiRef.current.state.editRows) + editingIds.forEach((id) => _muiDataGridApiRef.current.stopRowEditMode({ id, ignoreModifications: true })); + } + + const refreshRecords = async () => { + // + } + const observationsContext: IObservationsContext = { createNewRecord, + // getActiveRecords, + revertRecords, + saveRecords, + refreshRecords, _muiDataGridApiRef }; diff --git a/app/src/features/surveys/observations/ObservationComponent.tsx b/app/src/features/surveys/observations/ObservationComponent.tsx index d0732ff103..ee37dbf9d5 100644 --- a/app/src/features/surveys/observations/ObservationComponent.tsx +++ b/app/src/features/surveys/observations/ObservationComponent.tsx @@ -38,10 +38,19 @@ export const ObservationComponent = () => { {showSaveButton && ( <> - - diff --git a/app/src/features/surveys/observations/ObservationsTable.tsx b/app/src/features/surveys/observations/ObservationsTable.tsx index dd2da94790..debe6fb642 100644 --- a/app/src/features/surveys/observations/ObservationsTable.tsx +++ b/app/src/features/surveys/observations/ObservationsTable.tsx @@ -1,8 +1,6 @@ import { mdiDotsVertical, mdiTrashCan } from "@mdi/js"; import Icon from "@mdi/react"; -import { Theme } from "@mui/material"; import IconButton from '@mui/material/IconButton'; -import { makeStyles } from "@mui/styles"; import { DataGrid, GridColDef, GridEventListener, GridRowEditStopReasons } from '@mui/x-data-grid'; import { IObservationTableRow, ObservationsContext, fetchObservationDemoRows } from "contexts/observationsContext"; import useDataLoader from "hooks/useDataLoader"; @@ -12,12 +10,14 @@ import { useContext, useEffect, useState } from "react"; export type IObservationsTableProps = Record; +/* const useStyles = makeStyles((theme: Theme) => ({ modifiedRow: {} // { background: 'rgba(65, 168, 3, 0.16)' } })); +*/ const ObservationsTable = (props: IObservationsTableProps) => { - const classes = useStyles(); + // const classes = useStyles(); const observationsDataLoader = useDataLoader(fetchObservationDemoRows); const [initialRows, setInitialRows] = useState([]); @@ -143,20 +143,13 @@ const ObservationsTable = (props: IObservationsTableProps) => { } const handleRowEditStop: GridEventListener<'rowEditStop'> = (params, event) => { - // event.defaultMuiPrevented = true; + event.defaultMuiPrevented = true; return; if (params.reason === GridRowEditStopReasons.rowFocusOut) { // } }; - const handleProcessRowUpdate = (newRow: IObservationTableRow) => { - const updatedRow: IObservationTableRow = { ...newRow, _isModified: true }; - - // apiRef.current.setRows(Object.values(apiRef.current.state.rows).map((row) => (row.id === newRow.id ? updatedRow : row))); - return updatedRow; - }; - const handleCellClick: GridEventListener<'cellClick'> = (params, event) => { if (apiRef.current.state.editRows[params.row.id]) { return; @@ -180,7 +173,7 @@ const ObservationsTable = (props: IObservationsTableProps) => { onCellClick={handleCellClick} // onRowEditStart={handleRowEditStart} onRowEditStop={handleRowEditStop} - processRowUpdate={handleProcessRowUpdate} + // processRowUpdate={handleProcessRowUpdate} columns={observationColumns} rows={initialRows} // rowModesModel={_rowModesModel} @@ -197,13 +190,6 @@ const ObservationsTable = (props: IObservationsTableProps) => { } */ }} - getRowClassName={(params) => { - if (params.row._isModified) { - return classes.modifiedRow; - } - - return ''; - }} sx={{ background: '#fff', border: 'none', From 10a18baa84737a1a8e3ed115d07c24090e0cff0e Mon Sep 17 00:00:00 2001 From: Curtis Upshall Date: Mon, 25 Sep 2023 14:37:06 -0700 Subject: [PATCH 20/62] SIMSBIOHUB-223: Observations migrations --- .../repositories/observation-repository.ts | 13 ++ api/src/services/observation-service.ts | 14 ++ ...230925103600_create_survey_observations.ts | 193 ++++++++++++++++++ 3 files changed, 220 insertions(+) create mode 100644 api/src/repositories/observation-repository.ts create mode 100644 api/src/services/observation-service.ts create mode 100644 database/src/migrations/20230925103600_create_survey_observations.ts diff --git a/api/src/repositories/observation-repository.ts b/api/src/repositories/observation-repository.ts new file mode 100644 index 0000000000..ac3c889623 --- /dev/null +++ b/api/src/repositories/observation-repository.ts @@ -0,0 +1,13 @@ +import { FeatureCollection, GeoJsonProperties } from 'geojson'; +import { Knex } from 'knex'; +import SQL from 'sql-template-strings'; +import { SUBMISSION_MESSAGE_TYPE } from '../constants/status'; +import { getKnex } from '../database/db'; +import { appendSQLColumnsEqualValues, AppendSQLColumnsEqualValues } from '../utils/sql-utils'; +import { SubmissionErrorFromMessageType } from '../utils/submission-error'; +import { BaseRepository } from './base-repository'; + + +export class ObservationRepository extends BaseRepository { + +} diff --git a/api/src/services/observation-service.ts b/api/src/services/observation-service.ts new file mode 100644 index 0000000000..e74d15d62f --- /dev/null +++ b/api/src/services/observation-service.ts @@ -0,0 +1,14 @@ +import { GeoJsonProperties } from 'geojson'; +import { IDBConnection } from '../database/db'; +import { ObservationRepository } from '../repositories/observation-repository'; +import { DBService } from './db-service'; + +export class ObservationService extends DBService { + observationRepository: ObservationRepository; + + constructor(connection: IDBConnection) { + super(connection); + this.observationRepository = new ObservationRepository(connection); + } + +} diff --git a/database/src/migrations/20230925103600_create_survey_observations.ts b/database/src/migrations/20230925103600_create_survey_observations.ts new file mode 100644 index 0000000000..44c89a7797 --- /dev/null +++ b/database/src/migrations/20230925103600_create_survey_observations.ts @@ -0,0 +1,193 @@ +import { Knex } from 'knex'; + +/** + * @TODO doc + * @export + * @param {Knex} knex + * @return {*} {Promise} + */ +export async function up(knex: Knex): Promise { + await knex.raw(`--sql + + ---------------------------------------------------------------------------------------- + -- Create new survey_observation table + ---------------------------------------------------------------------------------------- + + SET search_path=biohub; + + CREATE TABLE survey_observation( + survey_observation_id integer GENERATED ALWAYS AS IDENTITY (START WITH 1 INCREMENT BY 1), + survey_id integer NOT NULL, + wldtaxonomic_units_id integer, + latlong point NOT NULL, + count integer NOT NULL, + observation_datetime timestamptz(6) NOT NULL, + create_date timestamptz(6) DEFAULT now() NOT NULL, + create_user integer NOT NULL, + update_date timestamptz(6), + update_user integer, + revision_count integer DEFAULT 0 NOT NULL, + CONSTRAINT survey_observation_pk PRIMARY KEY (survey_observation_id) + ); + + + COMMENT ON COLUMN survey_observation.survey_observation_id IS 'System generated surrogate primary key identifier.' + ; + COMMENT ON COLUMN survey_observation.survey_id IS 'A foreign key pointing to the survey table.' + ; + COMMENT ON COLUMN survey_observation.wldtaxonomic_units_id IS 'The species associated with the observation.' + ; + COMMENT ON COLUMN survey_observation.latlong IS 'The location of the observation.' + ; + COMMENT ON COLUMN survey_observation.count IS 'The count of the observation.' + ; + COMMENT ON COLUMN survey_observation.observation_datetime IS 'The timestamp associated with the observation.' + ; + COMMENT ON COLUMN survey_observation.create_date IS 'The datetime the record was created.' + ; + COMMENT ON COLUMN survey_observation.create_user IS 'The id of the user who created the record as identified in the system user table.' + ; + COMMENT ON COLUMN survey_observation.update_date IS 'The datetime the record was updated.' + ; + COMMENT ON COLUMN survey_observation.update_user IS 'The id of the user who updated the record as identified in the system user table.' + ; + COMMENT ON COLUMN survey_observation.revision_count IS 'Revision count used for concurrency control.' + ; + COMMENT ON TABLE survey_observation IS 'Broad classification for the survey_observation code of the survey.' + ; + + + + ---------------------------------------------------------------------------------------- + -- Create new keys and indices + ---------------------------------------------------------------------------------------- + + -- Create audit and journal triggers + create trigger audit_survey_observation before insert or update or delete on survey_observation for each row execute procedure tr_audit_trigger(); + create trigger journal_survey_observation after insert or update or delete on survey_observation for each row execute procedure tr_journal_trigger(); + + -- add foreign key constraints + ALTER TABLE survey_observation ADD CONSTRAINT survey_observation_fk1 + FOREIGN KEY (survey_id) + REFERENCES survey(survey_id); + + -- add indexes for foreign keys + CREATE INDEX survey_observation_idx1 ON survey_observation(survey_id); + + + + ---------------------------------------------------------------------------------------- + -- Create new views + ---------------------------------------------------------------------------------------- + + set search_path=biohub_dapi_v1; + + create or replace view survey_observation as select * from biohub.survey_observation; + + + + ---------------------------------------------------------------------------------------- + -- Update api_delete_survey procedure + ---------------------------------------------------------------------------------------- + + set search_path=biohub; + + CREATE OR REPLACE PROCEDURE api_delete_survey(p_survey_id integer) + LANGUAGE plpgsql + SECURITY DEFINER + AS $procedure$ + -- ******************************************************************* + -- Procedure: api_delete_survey + -- Purpose: deletes a survey and dependencies + -- + -- MODIFICATION HISTORY + -- Person Date Comments + -- ---------------- ----------- -------------------------------------- + -- shreyas.devalapurkar@quartech.com + -- 2021-06-18 initial release + -- charlie.garrettjones@quartech.com + -- 2021-06-21 added occurrence submission delete + -- charlie.garrettjones@quartech.com + -- 2021-09-21 added survey summary submission delete + -- kjartan.einarsson@quartech.com + -- 2022-08-28 added survey_vantage, survey_spatial_component, survey delete + -- charlie.garrettjones@quartech.com + -- 2022-09-07 changes to permit model + -- charlie.garrettjones@quartech.com + -- 2022-10-05 1.3.0 model changes + -- charlie.garrettjones@quartech.com + -- 2022-10-05 1.5.0 model changes, drop concept of occurrence deletion for published data + -- charlie.garrettjones@quartech.com + -- 2023-03-14 1.7.0 model changes + -- alfred.rosenthal@quartech.com + -- 2023-03-15 added missing publish tables to survey delete + -- curtis.upshall@quartech.com + -- 2023-04-28 change order of survey delete procedure + -- alfred.rosenthal@quartech.com + -- 2023-07-26 delete regions + -- curtis.upshall@quartech.com + -- 2023-08-24 delete partnerships + -- curtis.upshall@quartech.com + -- 2023-08-24 delete survey blocks and stratums and participation + -- curtis.upshall@quartech.com + -- 2023-09-25 delete survey observations + -- ******************************************************************* + declare + + begin + with occurrence_submissions as (select occurrence_submission_id from occurrence_submission where survey_id = p_survey_id), submission_spatial_components as (select submission_spatial_component_id from submission_spatial_component + where occurrence_submission_id in (select occurrence_submission_id from occurrence_submissions)) + delete from spatial_transform_submission where submission_spatial_component_id in (select submission_spatial_component_id from submission_spatial_components); + delete from submission_spatial_component where occurrence_submission_id in (select occurrence_submission_id from occurrence_submission where survey_id = p_survey_id); + + with occurrence_submissions as (select occurrence_submission_id from occurrence_submission where survey_id = p_survey_id) + , submission_statuses as (select submission_status_id from submission_status + where occurrence_submission_id in (select occurrence_submission_id from occurrence_submissions)) + delete from submission_message where submission_status_id in (select submission_status_id from submission_statuses); + delete from submission_status where occurrence_submission_id in (select occurrence_submission_id from occurrence_submission where survey_id = p_survey_id); + + delete from occurrence_submission_publish where occurrence_submission_id in (select occurrence_submission_id from occurrence_submission where survey_id = p_survey_id); + + delete from occurrence_submission where survey_id = p_survey_id; + + delete from survey_summary_submission_publish where survey_summary_submission_id in (select survey_summary_submission_id from survey_summary_submission where survey_id = p_survey_id); + delete from survey_summary_submission_message where survey_summary_submission_id in (select survey_summary_submission_id from survey_summary_submission where survey_id = p_survey_id); + delete from survey_summary_submission where survey_id = p_survey_id; + delete from survey_proprietor where survey_id = p_survey_id; + delete from survey_attachment_publish where survey_attachment_id in (select survey_attachment_id from survey_attachment where survey_id = p_survey_id); + delete from survey_attachment where survey_id = p_survey_id; + delete from survey_report_author where survey_report_attachment_id in (select survey_report_attachment_id from survey_report_attachment where survey_id = p_survey_id); + delete from survey_report_publish where survey_report_attachment_id in (select survey_report_attachment_id from survey_report_attachment where survey_id = p_survey_id); + delete from survey_report_attachment where survey_id = p_survey_id; + delete from study_species where survey_id = p_survey_id; + delete from survey_funding_source where survey_id = p_survey_id; + delete from survey_vantage where survey_id = p_survey_id; + delete from survey_spatial_component where survey_id = p_survey_id; + delete from survey_metadata_publish where survey_id = p_survey_id; + delete from survey_region where survey_id = p_survey_id; + delete from survey_first_nation_partnership where survey_id = p_survey_id; + delete from survey_block where survey_id = p_survey_id; + delete from permit where survey_id = p_survey_id; + delete from survey_type where survey_id = p_survey_id; + delete from survey_first_nation_partnership where survey_id = p_survey_id; + delete from survey_stakeholder_partnership where survey_id = p_survey_id; + delete from survey_participation where survey_id = p_survey_id; + delete from survey_stratum where survey_id = p_survey_id; + delete from survey_block where survey_id = p_survey_id; + delete from survey_site_strategy where survey_id = p_survey_id; + delete from survey_observation where survey_id = p_survey_id; + + -- delete the survey + delete from survey where survey_id = p_survey_id; + + exception + when others THEN + raise; + end; + $procedure$; + `); +} + +export async function down(knex: Knex): Promise { + await knex.raw(``); +} From 810070fa1d2e3741698d150b34573eabbab77b19 Mon Sep 17 00:00:00 2001 From: Curtis Upshall Date: Mon, 25 Sep 2023 16:19:02 -0700 Subject: [PATCH 21/62] SIMSBIOHUB-223: Added observation getter/setter endpoint --- .../survey/{surveyId}/observation/index.ts | 163 ++++++++++++++++++ .../repositories/observation-repository.ts | 79 ++++++++- api/src/services/observation-service.ts | 26 ++- app/src/hooks/api/useObservationApi.ts | 28 ++- 4 files changed, 286 insertions(+), 10 deletions(-) create mode 100644 api/src/paths/project/{projectId}/survey/{surveyId}/observation/index.ts diff --git a/api/src/paths/project/{projectId}/survey/{surveyId}/observation/index.ts b/api/src/paths/project/{projectId}/survey/{surveyId}/observation/index.ts new file mode 100644 index 0000000000..565db0ac0e --- /dev/null +++ b/api/src/paths/project/{projectId}/survey/{surveyId}/observation/index.ts @@ -0,0 +1,163 @@ +import { RequestHandler } from 'express'; +import { Operation } from 'express-openapi'; +import { authorizeRequestHandler } from '../../../../../../request-handlers/security/authorization'; +import { getLogger } from '../../../../../../utils/logger'; +import { PROJECT_PERMISSION, SYSTEM_ROLE } from '../../../../../../constants/roles'; +import { getDBConnection } from '../../../../../../database/db'; +import { ObservationService } from '../../../../../../services/observation-service'; + +const defaultLog = getLogger('/api/project/{projectId}/survey/{surveyId}/observation/get'); + +export const GET: Operation = [ + authorizeRequestHandler((req) => { + return { + or: [ + { + validProjectPermissions: [ + PROJECT_PERMISSION.COORDINATOR, + PROJECT_PERMISSION.COLLABORATOR, + PROJECT_PERMISSION.OBSERVER + ], + projectId: Number(req.params.projectId), + discriminator: 'ProjectPermission' + }, + { + validSystemRoles: [SYSTEM_ROLE.DATA_ADMINISTRATOR], + discriminator: 'SystemRole' + } + ] + }; + }), + getSurveyObservations() +]; + +GET.apiDoc = { + description: 'Fetches observation records for the given survey.', + tags: ['observation'], + security: [ + { + Bearer: [] + } + ], + parameters: [ + { + in: 'path', + name: 'projectId', + schema: { + type: 'number', + minimum: 1 + }, + required: true + }, + { + in: 'path', + name: 'surveyId', + schema: { + type: 'number', + minimum: 1 + }, + required: true + } + ], + responses: { + 200: { + description: 'Survey Observations get response.', + content: { + 'application/json': { + schema: { + title: 'Survey get response object, for view purposes', + type: 'object', + nullable: true, + required: ['surveyObservationData', 'surveyObservationSupplementaryData'], + properties: { + surveyObservations: { + type: 'object', + properties: { + survey_observation_id: { + type: 'integer' + }, + wldtaxonomic_units_id: { + type: 'integer' + }, + latlong: { + type: 'integer' + }, + count: { + type: 'integer' + }, + observation_datetime: { + type: 'string' + }, + create_date: { + oneOf: [{ type: 'object' }, { type: 'string', format: 'date' }], + description: 'ISO 8601 date string for the project start date' + }, + create_user: { + type: 'integer', + minimum: 1 + }, + update_date: { + oneOf: [{ type: 'object' }, { type: 'string', format: 'date' }], + description: 'ISO 8601 date string for the project start date', + nullable: true + }, + update_user: { + type: 'integer', + minimum: 1, + nullable: true + }, + revision_count: { + type: 'integer', + minimum: 0 + } + } + } + } + } + } + } + }, + 400: { + $ref: '#/components/responses/400' + }, + 401: { + $ref: '#/components/responses/401' + }, + 403: { + $ref: '#/components/responses/401' + }, + 500: { + $ref: '#/components/responses/500' + }, + default: { + $ref: '#/components/responses/default' + } + } +}; + +// TODO add PUT method here + +export function getSurveyObservations(): RequestHandler { + return async (req, res) => { + const surveyId = Number(req.params.surveyId); + + defaultLog.debug({ label: 'getSurveyObservations', surveyId }); + + const connection = getDBConnection(req['keycloak_token']); + + try { + await connection.open(); + + const observationService = new ObservationService(connection); + + const surveyObservations = observationService.getSurveyObservations(surveyId); + return res.status(200).json({ surveyObservations }); + } catch (error) { + defaultLog.error({ label: 'getSurveyObservations', message: 'error', error }); + await connection.rollback(); + throw error; + } finally { + connection.release(); + } + }; +} diff --git a/api/src/repositories/observation-repository.ts b/api/src/repositories/observation-repository.ts index ac3c889623..a5ad99dbb9 100644 --- a/api/src/repositories/observation-repository.ts +++ b/api/src/repositories/observation-repository.ts @@ -1,13 +1,78 @@ -import { FeatureCollection, GeoJsonProperties } from 'geojson'; -import { Knex } from 'knex'; -import SQL from 'sql-template-strings'; -import { SUBMISSION_MESSAGE_TYPE } from '../constants/status'; import { getKnex } from '../database/db'; -import { appendSQLColumnsEqualValues, AppendSQLColumnsEqualValues } from '../utils/sql-utils'; -import { SubmissionErrorFromMessageType } from '../utils/submission-error'; import { BaseRepository } from './base-repository'; +import { z } from 'zod'; +import { ApiExecuteSQLError } from '../errors/api-error'; +export const ObservationRecord = z.object({ + survey_observation_id: z.number(), + wldtaxonomic_units_id: z.number(), + latlong: z.any(), + count: z.number(), + observation_datetime: z.string(), + create_date: z.string(), + revision_count: z.number(), +}); + +export type ObservationRecord = z.infer; + +export type InsertObservation = Pick; + +export type UpdateObservation = Pick; export class ObservationRepository extends BaseRepository { - + /** + * TODO + * + * @param {number} surveyId + * @param {((Observation | ObservationRecord)[])} observations + * @return {*} {Promise} + * @memberof ObservationRepository + */ + async insertUpdateSurveyObservations(surveyId: number, observations: (InsertObservation | UpdateObservation)[]): Promise { + const insertQuery = getKnex() + .insert(observations.map((observation) => ({ ...observation, survey_id: surveyId }))) + .into('survey_observation') + .onConflict('survey_observation_pk') + .merge() + .returning('*') + + const response = await this.connection.knex(insertQuery, ObservationRecord); + + if (!response.rows.length) { + throw new ApiExecuteSQLError('Failed to insert/update survey observations', [ + 'ObservationRepository->insertUpdateSurveyObservations' + ]); + } + + return response.rows; + } + + /** + * @TODO + * + * @param {number} surveyId + * @return {*} {Promise} + * @memberof ObservationRepository + */ + async getSurveyObservations(surveyId: number): Promise { + const selectQuery = getKnex() + .select('*') + .from('survey_observation') + .where('survey_id', surveyId) + + const response = await this.connection.knex(selectQuery, ObservationRecord); + return response.rows; + } + } diff --git a/api/src/services/observation-service.ts b/api/src/services/observation-service.ts index e74d15d62f..49995f2a5a 100644 --- a/api/src/services/observation-service.ts +++ b/api/src/services/observation-service.ts @@ -1,6 +1,5 @@ -import { GeoJsonProperties } from 'geojson'; import { IDBConnection } from '../database/db'; -import { ObservationRepository } from '../repositories/observation-repository'; +import { InsertObservation, ObservationRecord, ObservationRepository, UpdateObservation } from '../repositories/observation-repository'; import { DBService } from './db-service'; export class ObservationService extends DBService { @@ -11,4 +10,27 @@ export class ObservationService extends DBService { this.observationRepository = new ObservationRepository(connection); } + /** + * TODO + * + * @param {number} surveyId + * @param {((Observation | ObservationRecord)[])} observations + * @return {*} {Promise} + * @memberof ObservationService + */ + async insertUpdateSurveyObservations(surveyId: number, observations: (InsertObservation | UpdateObservation)[]): Promise { + return this.observationRepository.insertUpdateSurveyObservations(surveyId, observations); + } + + /** + * TODO + * + * @param {number} surveyId + * @return {*} {Promise} + * @memberof ObservationService + */ + async getSurveyObservations(surveyId: number): Promise { + return this.observationRepository.getSurveyObservations(surveyId); + } + } diff --git a/app/src/hooks/api/useObservationApi.ts b/app/src/hooks/api/useObservationApi.ts index 35c68a0248..06cf6e9935 100644 --- a/app/src/hooks/api/useObservationApi.ts +++ b/app/src/hooks/api/useObservationApi.ts @@ -1,4 +1,5 @@ import { AxiosInstance, CancelTokenSource } from 'axios'; +import { IObservationTableRow } from 'contexts/observationsContext'; import { GeoJsonProperties } from 'geojson'; import { IGetObservationSubmissionResponse, @@ -172,6 +173,29 @@ const useObservationApi = (axios: AxiosInstance) => { return data; }; + /** + * TODO + * + * @param {number} projectId + * @param {number} surveyId + * @param {IObservationTableRow[]} surveyObservations + * @return {*} + */ + const insertUpdateObservationRecords = async (projectId: number, surveyId: number, surveyObservations: IObservationTableRow[]) => { + const { data } = await axios.put( + `/api/project/${projectId}/survey/${surveyId}/observation`, + { surveyObservations } + ); + + return data; + } + + const getObservationRecords = async (projectId: number, surveyId: number) => { + const { data } = await axios.get(`/api/project/${projectId}/survey/${surveyId}/observation`); + + return data; + } + return { uploadObservationSubmission, getObservationSubmission, @@ -181,7 +205,9 @@ const useObservationApi = (axios: AxiosInstance) => { getOccurrencesForView, processOccurrences, processDWCFile, - getSpatialMetadata + getSpatialMetadata, + insertUpdateObservationRecords, + getObservationRecords }; }; From 2d70a7048e88af1dc943763b4ad157eafbaef765 Mon Sep 17 00:00:00 2001 From: Curtis Upshall Date: Tue, 26 Sep 2023 08:34:18 -0700 Subject: [PATCH 22/62] SIMSBIOHUB-223: Added endpoint logic --- .../survey/{surveyId}/observation/index.ts | 157 +++++++++++++++++- .../repositories/observation-repository.ts | 14 +- app/src/contexts/observationsContext.tsx | 32 ++-- .../observations/ObservationComponent.tsx | 20 ++- .../observations/ObservationsTable.tsx | 4 +- 5 files changed, 200 insertions(+), 27 deletions(-) diff --git a/api/src/paths/project/{projectId}/survey/{surveyId}/observation/index.ts b/api/src/paths/project/{projectId}/survey/{surveyId}/observation/index.ts index 565db0ac0e..656aa42688 100644 --- a/api/src/paths/project/{projectId}/survey/{surveyId}/observation/index.ts +++ b/api/src/paths/project/{projectId}/survey/{surveyId}/observation/index.ts @@ -5,6 +5,8 @@ import { getLogger } from '../../../../../../utils/logger'; import { PROJECT_PERMISSION, SYSTEM_ROLE } from '../../../../../../constants/roles'; import { getDBConnection } from '../../../../../../database/db'; import { ObservationService } from '../../../../../../services/observation-service'; +import { InsertObservation, UpdateObservation } from '../../../../../../repositories/observation-repository'; +import { TaxonomyService } from '../../../../../../services/taxonomy-service'; const defaultLog = getLogger('/api/project/{projectId}/survey/{surveyId}/observation/get'); @@ -31,6 +33,25 @@ export const GET: Operation = [ getSurveyObservations() ]; +export const PUT: Operation = [ + authorizeRequestHandler((req) => { + return { + or: [ + { + validProjectPermissions: [PROJECT_PERMISSION.COORDINATOR, PROJECT_PERMISSION.COLLABORATOR], + projectId: Number(req.params.projectId), + discriminator: 'ProjectPermission' + }, + { + validSystemRoles: [SYSTEM_ROLE.DATA_ADMINISTRATOR], + discriminator: 'SystemRole' + } + ] + }; + }), + insertUpdateSurveyObservations() +]; + GET.apiDoc = { description: 'Fetches observation records for the given survey.', tags: ['observation'], @@ -135,7 +156,99 @@ GET.apiDoc = { } }; -// TODO add PUT method here +PUT.apiDoc = { + description: 'Fetches observation records for the given survey.', + tags: ['attachments'], + security: [ + { + Bearer: [] + } + ], + parameters: [ + { + in: 'path', + name: 'projectId', + required: true + }, + { + in: 'path', + name: 'surveyId', + required: true + } + ], + requestBody: { + description: 'Survey observation record data', + content: { + 'multipart/form-data': { + schema: { + type: 'object', + properties: { + surveyObservations: { + description: 'Survey observation reords.', + type: 'array', + items: { + type: 'object', + required: ['speciesName', 'count', 'latitude', 'longitude', 'date', 'time'], + properties: { + speciesName: { + type: 'string' + }, + count: { + type: 'string' + }, + latitude: { + type: 'number' + }, + longitude: { + type: 'number' + }, + date: { + type: 'string' + }, + time: { + type: 'string' + } + } + } + } + } + } + } + } + }, + responses: { + 200: { + description: 'Upload OK', + content: { + 'application/json': { + schema: { + type: 'object', + properties: { + submissionId: { + type: 'number' + } + } + } + } + } + }, + 400: { + $ref: '#/components/responses/400' + }, + 401: { + $ref: '#/components/responses/401' + }, + 403: { + $ref: '#/components/responses/401' + }, + 500: { + $ref: '#/components/responses/500' + }, + default: { + $ref: '#/components/responses/default' + } + } +}; export function getSurveyObservations(): RequestHandler { return async (req, res) => { @@ -161,3 +274,45 @@ export function getSurveyObservations(): RequestHandler { } }; } + +export function insertUpdateSurveyObservations(): RequestHandler { + return async (req, res) => { + const surveyId = Number(req.params.surveyId); + + defaultLog.debug({ label: 'insertUpdateSurveyObservations', surveyId }); + + const connection = getDBConnection(req['keycloak_token']); + + try { + await connection.open(); + + const observationService = new ObservationService(connection); + const taxonomyService = new TaxonomyService(); + + const promises: Promise<(InsertObservation | UpdateObservation)>[] = req.body.map(async (record: any) => { + const taxonCodes = await taxonomyService.searchSpecies(record.speciesName.toLowerCase()); + + return { + survey_id: surveyId, + survey_observation_id: record.survey_observation_id, + wldtaxonomic_units_id: taxonCodes[0].id, + latitude: record.latitude, + longitude: record.longitude, + count: record.count, + observation_datetime: new Date(`${record.date} ${record.time}`) + }; + }); + + const records = await Promise.all(promises); + + const surveyObservations = observationService.insertUpdateSurveyObservations(surveyId, records); + return res.status(200).json({ surveyObservations }); + } catch (error) { + defaultLog.error({ label: 'insertUpdateSurveyObservations', message: 'error', error }); + await connection.rollback(); + throw error; + } finally { + connection.release(); + } + }; +} diff --git a/api/src/repositories/observation-repository.ts b/api/src/repositories/observation-repository.ts index a5ad99dbb9..cd7a1f7d7b 100644 --- a/api/src/repositories/observation-repository.ts +++ b/api/src/repositories/observation-repository.ts @@ -5,8 +5,10 @@ import { ApiExecuteSQLError } from '../errors/api-error'; export const ObservationRecord = z.object({ survey_observation_id: z.number(), + survey_id: z.number(), wldtaxonomic_units_id: z.number(), - latlong: z.any(), + latitude: z.number(), + longitude: z.number(), count: z.number(), observation_datetime: z.string(), create_date: z.string(), @@ -16,16 +18,20 @@ export const ObservationRecord = z.object({ export type ObservationRecord = z.infer; export type InsertObservation = Pick; export type UpdateObservation = Pick; @@ -41,7 +47,7 @@ export class ObservationRepository extends BaseRepository { */ async insertUpdateSurveyObservations(surveyId: number, observations: (InsertObservation | UpdateObservation)[]): Promise { const insertQuery = getKnex() - .insert(observations.map((observation) => ({ ...observation, survey_id: surveyId }))) + .insert(observations) .into('survey_observation') .onConflict('survey_observation_pk') .merge() diff --git a/app/src/contexts/observationsContext.tsx b/app/src/contexts/observationsContext.tsx index eca25ceca0..0203eee6c4 100644 --- a/app/src/contexts/observationsContext.tsx +++ b/app/src/contexts/observationsContext.tsx @@ -1,7 +1,9 @@ import { useGridApiRef } from '@mui/x-data-grid'; import { GridApiCommunity } from '@mui/x-data-grid/internals'; -import { createContext, PropsWithChildren } from 'react'; +import { useBiohubApi } from 'hooks/useBioHubApi'; +import { createContext, PropsWithChildren, useContext } from 'react'; import { v4 as uuidv4 } from 'uuid'; +import { SurveyContext } from './surveyContext'; export interface IObservationRecord { observation_id: number | undefined; @@ -12,8 +14,8 @@ export interface IObservationRecord { count: number | undefined; date: string | undefined; time: string | undefined; - lat: number | undefined; - long: number | undefined; + latitude: number | undefined; + longitude: number | undefined; } export interface IObservationTableRow extends Partial { @@ -33,8 +35,8 @@ export const fetchObservationDemoRows = async (): Promise count: undefined, date: undefined, time: undefined, - lat: undefined, - long: undefined + latitude: undefined, + longitude: undefined }, { observation_id: 2, @@ -45,8 +47,8 @@ export const fetchObservationDemoRows = async (): Promise count: undefined, date: undefined, time: undefined, - lat: undefined, - long: undefined + latitude: undefined, + longitude: undefined }, { observation_id: 3, @@ -57,8 +59,8 @@ export const fetchObservationDemoRows = async (): Promise count: undefined, date: undefined, time: undefined, - lat: undefined, - long: undefined + latitude: undefined, + longitude: undefined } ]) }, 1000 * (Math.random() + 1)); @@ -91,6 +93,8 @@ export const ObservationsContext = createContext({ export const ObservationsContextProvider = (props: PropsWithChildren>) => { const _muiDataGridApiRef = useGridApiRef(); + const biohubApi = useBiohubApi(); + const surveyContext = useContext(SurveyContext); /* const _getRows = (): IObservationTableRow[] => { @@ -139,14 +143,10 @@ export const ObservationsContextProvider = (props: PropsWithChildren _muiDataGridApiRef.current.stopRowEditMode({ id })); - /* - const rows = getActiveRecords() - .map((row) => ({ ...row, _isModified: false })) + const { projectId, surveyId } = surveyContext; + const rows = Array.from(_muiDataGridApiRef.current.getRowModels?.()?.values()) as IObservationTableRow[] - console.log(rows); - localStorage.setItem('__OBSERVATIONS_TEST', JSON.stringify(rows)) - return Promise.resolve() - */ + return biohubApi.observation.insertUpdateObservationRecords(projectId, surveyId, rows) } const revertRecords = async () => { diff --git a/app/src/features/surveys/observations/ObservationComponent.tsx b/app/src/features/surveys/observations/ObservationComponent.tsx index ee37dbf9d5..9d0e9f2ea8 100644 --- a/app/src/features/surveys/observations/ObservationComponent.tsx +++ b/app/src/features/surveys/observations/ObservationComponent.tsx @@ -1,16 +1,27 @@ import { mdiCogOutline, mdiFloppy, mdiImport, mdiPlus, mdiTrashCan } from '@mdi/js'; import Icon from '@mdi/react'; +import { LoadingButton } from '@mui/lab'; import Box from '@mui/material/Box'; import Button from '@mui/material/Button'; import Toolbar from '@mui/material/Toolbar'; import Typography from '@mui/material/Typography'; import { ObservationsContext } from 'contexts/observationsContext'; import ObservationsTable from 'features/surveys/observations/ObservationsTable'; -import { useContext } from 'react'; +import { useContext, useState } from 'react'; export const ObservationComponent = () => { const observationsContext = useContext(ObservationsContext); + const [isSaving, setIsSaving] = useState(false); + const handleSaveChanges = () => { + setIsSaving(true); + + observationsContext.saveRecords().finally(() => { + setIsSaving(false); + }); + } + + // TODO: only show save button when there are unsaved changes const showSaveButton = true; return ( @@ -38,14 +49,15 @@ export const ObservationComponent = () => { {showSaveButton && ( <> - + diff --git a/app/src/hooks/api/useObservationApi.ts b/app/src/hooks/api/useObservationApi.ts index 06cf6e9935..562650821b 100644 --- a/app/src/hooks/api/useObservationApi.ts +++ b/app/src/hooks/api/useObservationApi.ts @@ -179,22 +179,25 @@ const useObservationApi = (axios: AxiosInstance) => { * @param {number} projectId * @param {number} surveyId * @param {IObservationTableRow[]} surveyObservations - * @return {*} + * @return {*} */ - const insertUpdateObservationRecords = async (projectId: number, surveyId: number, surveyObservations: IObservationTableRow[]) => { - const { data } = await axios.put( - `/api/project/${projectId}/survey/${surveyId}/observation`, - { surveyObservations } - ); + const insertUpdateObservationRecords = async ( + projectId: number, + surveyId: number, + surveyObservations: IObservationTableRow[] + ) => { + const { data } = await axios.put(`/api/project/${projectId}/survey/${surveyId}/observation`, { + surveyObservations + }); return data; - } + }; const getObservationRecords = async (projectId: number, surveyId: number) => { const { data } = await axios.get(`/api/project/${projectId}/survey/${surveyId}/observation`); return data; - } + }; return { uploadObservationSubmission, From a61d29816916665df68f05fcf6bbc41e2800d9c9 Mon Sep 17 00:00:00 2001 From: Curtis Upshall Date: Tue, 26 Sep 2023 11:07:30 -0700 Subject: [PATCH 30/62] SIMSBIOHUB-223: Some PR changes --- .../survey/{surveyId}/observation/index.ts | 35 ++++++++++++------- 1 file changed, 22 insertions(+), 13 deletions(-) diff --git a/api/src/paths/project/{projectId}/survey/{surveyId}/observation/index.ts b/api/src/paths/project/{projectId}/survey/{surveyId}/observation/index.ts index 2a02ea8c0a..261ad3eb3e 100644 --- a/api/src/paths/project/{projectId}/survey/{surveyId}/observation/index.ts +++ b/api/src/paths/project/{projectId}/survey/{surveyId}/observation/index.ts @@ -7,7 +7,7 @@ import { authorizeRequestHandler } from '../../../../../../request-handlers/secu import { ObservationService } from '../../../../../../services/observation-service'; import { getLogger } from '../../../../../../utils/logger'; -const defaultLog = getLogger('/api/project/{projectId}/survey/{surveyId}/observation/get'); +const defaultLog = getLogger('/api/project/{projectId}/survey/{surveyId}/observation'); export const GET: Operation = [ authorizeRequestHandler((req) => { @@ -88,10 +88,23 @@ GET.apiDoc = { title: 'Survey get response object, for view purposes', type: 'object', nullable: true, - required: ['surveyObservationData', 'surveyObservationSupplementaryData'], + required: ['surveyObservations'], properties: { surveyObservations: { type: 'object', + required: [ + 'survey_observation_id', + 'wldtaxonomic_units_id', + 'latitude', + 'longitude', + 'count', + 'observation_datetime', + 'create_user', + 'create_date', + 'update_user', + 'update_date', + 'revision_count' + ], properties: { survey_observation_id: { type: 'integer' @@ -99,8 +112,11 @@ GET.apiDoc = { wldtaxonomic_units_id: { type: 'integer' }, - latlong: { - type: 'integer' + latitude: { + type: 'number' + }, + longitude: { + type: 'number' }, count: { type: 'integer' @@ -219,14 +235,7 @@ PUT.apiDoc = { 200: { description: 'Upload OK', content: { - 'application/json': { - schema: { - type: 'object', - properties: { - // TODO do we need to include anything in this response? - } - } - } + 'application/json': {} } }, 400: { @@ -315,7 +324,7 @@ export function insertUpdateSurveyObservations(): RequestHandler { await observationService.insertUpdateSurveyObservations(surveyId, records); - return res.status(200).json({}); + return res.status(200).send(); } catch (error) { defaultLog.error({ label: 'insertUpdateSurveyObservations', message: 'error', error }); await connection.rollback(); From 834640b3fa1e0083169b51bfe7156fd1a94dedae Mon Sep 17 00:00:00 2001 From: Curtis Upshall Date: Tue, 26 Sep 2023 11:41:04 -0700 Subject: [PATCH 31/62] SIMSBIOHUB-223: Update datagrid and sql table to use discrete date and time columns --- .../survey/{surveyId}/observation/index.ts | 20 +++-- .../repositories/observation-repository.ts | 86 +++++++++++-------- app/src/contexts/observationsContext.tsx | 16 ++-- .../observations/ObservationsTable.tsx | 4 +- 4 files changed, 73 insertions(+), 53 deletions(-) diff --git a/api/src/paths/project/{projectId}/survey/{surveyId}/observation/index.ts b/api/src/paths/project/{projectId}/survey/{surveyId}/observation/index.ts index 261ad3eb3e..05e18588bb 100644 --- a/api/src/paths/project/{projectId}/survey/{surveyId}/observation/index.ts +++ b/api/src/paths/project/{projectId}/survey/{surveyId}/observation/index.ts @@ -98,7 +98,8 @@ GET.apiDoc = { 'latitude', 'longitude', 'count', - 'observation_datetime', + 'observation_date', + 'observation_time', 'create_user', 'create_date', 'update_user', @@ -121,7 +122,10 @@ GET.apiDoc = { count: { type: 'integer' }, - observation_datetime: { + observation_date: { + type: 'string' + }, + observation_time: { type: 'string' }, create_date: { @@ -203,7 +207,7 @@ PUT.apiDoc = { type: 'array', items: { type: 'object', - required: ['speciesName', 'count', 'latitude', 'longitude', 'date', 'time'], + required: ['speciesName', 'count', 'latitude', 'longitude', 'observation_date', 'observation_time'], properties: { speciesName: { type: 'string' @@ -217,10 +221,10 @@ PUT.apiDoc = { longitude: { type: 'number' }, - date: { + observation_date: { type: 'string' }, - time: { + observation_time: { type: 'string' } } @@ -304,7 +308,8 @@ export function insertUpdateSurveyObservations(): RequestHandler { latitude: record.latitude, longitude: record.longitude, count: record.count, - observation_datetime: new Date(`${record.date} ${record.time}`) + observation_date: record.observation_date, + observation_time: record.observation_time })) }); const records = await Promise.all(promises); @@ -318,7 +323,8 @@ export function insertUpdateSurveyObservations(): RequestHandler { latitude: record.latitude, longitude: record.longitude, count: record.count, - observation_datetime: new Date(`${record.date} ${record.time}`) + observation_date: record.observation_date, + observation_time: record.observation_time }; }); diff --git a/api/src/repositories/observation-repository.ts b/api/src/repositories/observation-repository.ts index a7487bc1a5..a97ac90179 100644 --- a/api/src/repositories/observation-repository.ts +++ b/api/src/repositories/observation-repository.ts @@ -1,6 +1,8 @@ import { z } from 'zod'; import { getKnex } from '../database/db'; import { BaseRepository } from './base-repository'; +import { ApiExecuteSQLError } from '../errors/api-error'; +import SQL from 'sql-template-strings'; export const ObservationRecord = z.object({ survey_observation_id: z.number(), @@ -9,7 +11,8 @@ export const ObservationRecord = z.object({ latitude: z.number(), longitude: z.number(), count: z.number(), - observation_datetime: z.string(), + observation_time: z.string(), + observation_date: z.string(), create_date: z.string(), revision_count: z.number() }); @@ -18,12 +21,12 @@ export type ObservationRecord = z.infer; export type InsertObservation = Pick< ObservationRecord, - 'survey_id' | 'wldtaxonomic_units_id' | 'latitude' | 'longitude' | 'count' | 'observation_datetime' + 'survey_id' | 'wldtaxonomic_units_id' | 'latitude' | 'longitude' | 'count' | 'observation_date' | 'observation_time' >; export type UpdateObservation = Pick< ObservationRecord, - 'survey_observation_id' | 'wldtaxonomic_units_id' | 'latitude' | 'longitude' | 'count' | 'observation_datetime' + 'survey_observation_id' | 'wldtaxonomic_units_id' | 'latitude' | 'longitude' | 'count' | 'observation_date' | 'observation_time' >; export class ObservationRepository extends BaseRepository { @@ -39,40 +42,52 @@ export class ObservationRepository extends BaseRepository { surveyId: number, observations: (InsertObservation | UpdateObservation)[] ): Promise { - const knex = getKnex(); + + const query = SQL` + INSERT INTO + survey_observation + ( + survey_observation_id, + survey_id, + wldtaxonomic_units_id, + count, + latitude, + longitude, + observation_date, + observation_time + ) VALUES + `; + + query.append(observations.map((observation) => { + return `(${[ + observation['survey_observation_id'] || 'NULL', + surveyId, + observation.wldtaxonomic_units_id, + observation.count, + observation.latitude, + observation.longitude, + observation.observation_date, + observation.observation_time + ].join(', ')})`; + }).join(', ')); - const query = knex.queryBuilder() - .insert( - observations.map((observation) => { - return { - survey_id: surveyId, - ...observation - /* - survey_observation_id: observation['survey_observation_id'], - wldtaxonomic_units_id: observation.wldtaxonomic_units_id, - count: observation.count, - observation_datetime: observation.observation_datetime, - latlong: knex.raw(`POINT(${observation.latitude}, ${observation.longitude})`) - */ - }; - }) - ) - .into('survey_observation') - .onConflict(` + query.append(` + ON CONFLICT (survey_observation_id) - DO UPDATE SET - wldtaxonomic_units_id = EXCLUDED.wldtaxonomic_units_id, - count = EXCLUDED.count, - observation_datetime = EXCLUDED.observation_datetime, - latitude = EXCLUDED.latitude, - longitude = EXCLUDED.longitude, - RETURNING *; - `) - - console.log('query:', String(JSON.stringify(query))); + DO UPDATE SET + wldtaxonomic_units_id = EXCLUDED.wldtaxonomic_units_id, + count = EXCLUDED.count, + observation_date = EXCLUDED.observation_date, + observation_time = EXCLUDED.observation_time, + latitude = EXCLUDED.latitude, + longitude = EXCLUDED.longitude, + RETURNING *; + `) - /* - const response = await this.connection.query(query, ObservationRecord); + console.log(query.text) + console.log(query.values) + + const response = await this.connection.sql(query, ObservationRecord); if (!response.rows.length) { throw new ApiExecuteSQLError('Failed to insert/update survey observations', [ @@ -81,8 +96,7 @@ export class ObservationRepository extends BaseRepository { } return response.rows; - */ - return []; + } /** diff --git a/app/src/contexts/observationsContext.tsx b/app/src/contexts/observationsContext.tsx index 4ef0169625..76622132fe 100644 --- a/app/src/contexts/observationsContext.tsx +++ b/app/src/contexts/observationsContext.tsx @@ -12,8 +12,8 @@ export interface IObservationRecord { samplingMethod: string | undefined; samplingPeriod: string | undefined; count: number | undefined; - date: Date | undefined; - time: string | undefined; + observation_date: Date | undefined; + observation_time: string | undefined; latitude: number | undefined; longitude: number | undefined; } @@ -33,8 +33,8 @@ export const fetchObservationDemoRows = async (): Promise samplingMethod: 'Method 1', samplingPeriod: undefined, count: 1, - date: new Date('2020-01-01'), - time: '12:00:00', + observation_date: new Date('2020-01-01'), + observation_time: '12:00:00', latitude: 45, longitude: 125 }, @@ -45,8 +45,8 @@ export const fetchObservationDemoRows = async (): Promise samplingMethod: 'Method 1', samplingPeriod: undefined, count: 2, - date: new Date('2021-01-01'), - time: '13:00:00', + observation_date: new Date('2021-01-01'), + observation_time: '13:00:00', latitude: 46, longitude: 126 }, @@ -57,8 +57,8 @@ export const fetchObservationDemoRows = async (): Promise samplingMethod: 'Method 1', samplingPeriod: undefined, count: 3, - date: new Date('2022-01-01'), - time: '14:00:00', + observation_date: new Date('2022-01-01'), + observation_time: '14:00:00', latitude: 47, longitude: 127 } diff --git a/app/src/features/surveys/observations/ObservationsTable.tsx b/app/src/features/surveys/observations/ObservationsTable.tsx index b9790bee82..8a58582047 100644 --- a/app/src/features/surveys/observations/ObservationsTable.tsx +++ b/app/src/features/surveys/observations/ObservationsTable.tsx @@ -71,7 +71,7 @@ const ObservationsTable = (props: IObservationsTableProps) => { disableColumnMenu: true }, { - field: 'date', + field: 'observation_date', headerName: 'Date', editable: true, type: 'date', @@ -79,7 +79,7 @@ const ObservationsTable = (props: IObservationsTableProps) => { disableColumnMenu: true }, { - field: 'time', + field: 'observation_date', headerName: 'Time', editable: true, type: 'time', From 53fc02f4246be3d3231b7f055b6f6224a868e720 Mon Sep 17 00:00:00 2001 From: Curtis Upshall Date: Tue, 26 Sep 2023 11:42:19 -0700 Subject: [PATCH 32/62] SIMSBIOHUB-223: createNewRecord update --- app/src/contexts/observationsContext.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/src/contexts/observationsContext.tsx b/app/src/contexts/observationsContext.tsx index 76622132fe..fa13c6dd01 100644 --- a/app/src/contexts/observationsContext.tsx +++ b/app/src/contexts/observationsContext.tsx @@ -114,8 +114,8 @@ export const ObservationsContextProvider = (props: PropsWithChildren Date: Tue, 26 Sep 2023 11:49:59 -0700 Subject: [PATCH 33/62] SIMSBIOHUB-223: table updates --- api/src/repositories/observation-repository.ts | 7 ++++--- .../20230925103600_create_survey_observations.ts | 7 +++++-- 2 files changed, 9 insertions(+), 5 deletions(-) diff --git a/api/src/repositories/observation-repository.ts b/api/src/repositories/observation-repository.ts index a97ac90179..772d03b7db 100644 --- a/api/src/repositories/observation-repository.ts +++ b/api/src/repositories/observation-repository.ts @@ -3,6 +3,7 @@ import { getKnex } from '../database/db'; import { BaseRepository } from './base-repository'; import { ApiExecuteSQLError } from '../errors/api-error'; import SQL from 'sql-template-strings'; +import moment from 'moment'; export const ObservationRecord = z.object({ survey_observation_id: z.number(), @@ -66,8 +67,8 @@ export class ObservationRepository extends BaseRepository { observation.count, observation.latitude, observation.longitude, - observation.observation_date, - observation.observation_time + `'${moment(observation.observation_date).format('YYYY-MM-DD')}'`, + `'${observation.observation_time}'` ].join(', ')})`; }).join(', ')); @@ -80,7 +81,7 @@ export class ObservationRepository extends BaseRepository { observation_date = EXCLUDED.observation_date, observation_time = EXCLUDED.observation_time, latitude = EXCLUDED.latitude, - longitude = EXCLUDED.longitude, + longitude = EXCLUDED.longitude RETURNING *; `) diff --git a/database/src/migrations/20230925103600_create_survey_observations.ts b/database/src/migrations/20230925103600_create_survey_observations.ts index b72c0c2795..bbd8cbe839 100644 --- a/database/src/migrations/20230925103600_create_survey_observations.ts +++ b/database/src/migrations/20230925103600_create_survey_observations.ts @@ -22,7 +22,8 @@ export async function up(knex: Knex): Promise { latitude float NOT NULL, longitude float NOT NULL, count integer NOT NULL, - observation_datetime timestamptz(6) NOT NULL, + observation_date date NOT NULL, + observation_time time NOT NULL, create_date timestamptz(6) DEFAULT now() NOT NULL, create_user integer NOT NULL, update_date timestamptz(6), @@ -44,7 +45,9 @@ export async function up(knex: Knex): Promise { ; COMMENT ON COLUMN survey_observation.count IS 'The count of the observation.' ; - COMMENT ON COLUMN survey_observation.observation_datetime IS 'The timestamp associated with the observation.' + COMMENT ON COLUMN survey_observation.observation_date IS 'The date associated with the observation.' + ; + COMMENT ON COLUMN survey_observation.observation_times IS 'The time associated with the observation.' ; COMMENT ON COLUMN survey_observation.create_date IS 'The datetime the record was created.' ; From fe9f71dc66b85968efd79d55d49b55a4a7f0aaad Mon Sep 17 00:00:00 2001 From: Curtis Upshall Date: Tue, 26 Sep 2023 16:07:27 -0700 Subject: [PATCH 34/62] SIMSBIOHUB-223: SQL insertion working --- api/src/repositories/observation-repository.ts | 12 +++++++++--- .../surveys/observations/ObservationsTable.tsx | 6 +----- .../20230925103600_create_survey_observations.ts | 2 +- 3 files changed, 11 insertions(+), 9 deletions(-) diff --git a/api/src/repositories/observation-repository.ts b/api/src/repositories/observation-repository.ts index 772d03b7db..0e61ecfc61 100644 --- a/api/src/repositories/observation-repository.ts +++ b/api/src/repositories/observation-repository.ts @@ -1,7 +1,6 @@ import { z } from 'zod'; import { getKnex } from '../database/db'; import { BaseRepository } from './base-repository'; -import { ApiExecuteSQLError } from '../errors/api-error'; import SQL from 'sql-template-strings'; import moment from 'moment'; @@ -56,12 +55,14 @@ export class ObservationRepository extends BaseRepository { longitude, observation_date, observation_time - ) VALUES + ) + OVERRIDING SYSTEM VALUE + VALUES `; query.append(observations.map((observation) => { return `(${[ - observation['survey_observation_id'] || 'NULL', + observation['survey_observation_id'] || 'DEFAULT', surveyId, observation.wldtaxonomic_units_id, observation.count, @@ -85,16 +86,21 @@ export class ObservationRepository extends BaseRepository { RETURNING *; `) + /* console.log(query.text) console.log(query.values) + */ const response = await this.connection.sql(query, ObservationRecord); + /* + // Not sure if this check is needed, as there may be certain cases where updates are idempotent if (!response.rows.length) { throw new ApiExecuteSQLError('Failed to insert/update survey observations', [ 'ObservationRepository->insertUpdateSurveyObservations' ]); } + */ return response.rows; diff --git a/app/src/features/surveys/observations/ObservationsTable.tsx b/app/src/features/surveys/observations/ObservationsTable.tsx index 8a58582047..429a3b9dcc 100644 --- a/app/src/features/surveys/observations/ObservationsTable.tsx +++ b/app/src/features/surveys/observations/ObservationsTable.tsx @@ -1,7 +1,7 @@ import { mdiDotsVertical, mdiTrashCan } from '@mdi/js'; import Icon from '@mdi/react'; import IconButton from '@mui/material/IconButton'; -import { DataGrid, GridColDef, GridEventListener, GridRowEditStopReasons, GridRowModelUpdate } from '@mui/x-data-grid'; +import { DataGrid, GridColDef, GridEventListener, GridRowModelUpdate } from '@mui/x-data-grid'; import { fetchObservationDemoRows, IObservationTableRow, ObservationsContext } from 'contexts/observationsContext'; import useDataLoader from 'hooks/useDataLoader'; import { useContext, useEffect, useState } from 'react'; @@ -140,10 +140,6 @@ const ObservationsTable = (props: IObservationsTableProps) => { const handleRowEditStop: GridEventListener<'rowEditStop'> = (params, event) => { event.defaultMuiPrevented = true; - return; - if (params.reason === GridRowEditStopReasons.rowFocusOut) { - // - } }; const handleCellClick: GridEventListener<'cellClick'> = (params, event) => { diff --git a/database/src/migrations/20230925103600_create_survey_observations.ts b/database/src/migrations/20230925103600_create_survey_observations.ts index bbd8cbe839..64a17c718c 100644 --- a/database/src/migrations/20230925103600_create_survey_observations.ts +++ b/database/src/migrations/20230925103600_create_survey_observations.ts @@ -47,7 +47,7 @@ export async function up(knex: Knex): Promise { ; COMMENT ON COLUMN survey_observation.observation_date IS 'The date associated with the observation.' ; - COMMENT ON COLUMN survey_observation.observation_times IS 'The time associated with the observation.' + COMMENT ON COLUMN survey_observation.observation_time IS 'The time associated with the observation.' ; COMMENT ON COLUMN survey_observation.create_date IS 'The datetime the record was created.' ; From ef51ccd5f264c69a015400bfd71d61d6d1d86e2e Mon Sep 17 00:00:00 2001 From: Curtis Upshall Date: Tue, 26 Sep 2023 16:25:51 -0700 Subject: [PATCH 35/62] SIMSBIOUB-223: endpoint valiation WIP --- .../survey/{surveyId}/observation/index.ts | 155 +++++++++--------- 1 file changed, 82 insertions(+), 73 deletions(-) diff --git a/api/src/paths/project/{projectId}/survey/{surveyId}/observation/index.ts b/api/src/paths/project/{projectId}/survey/{surveyId}/observation/index.ts index 05e18588bb..c03668b26d 100644 --- a/api/src/paths/project/{projectId}/survey/{surveyId}/observation/index.ts +++ b/api/src/paths/project/{projectId}/survey/{surveyId}/observation/index.ts @@ -6,6 +6,7 @@ import { InsertObservation, UpdateObservation } from '../../../../../../reposito import { authorizeRequestHandler } from '../../../../../../request-handlers/security/authorization'; import { ObservationService } from '../../../../../../services/observation-service'; import { getLogger } from '../../../../../../utils/logger'; +import { SchemaObject } from 'ajv'; const defaultLog = getLogger('/api/project/{projectId}/survey/{surveyId}/observation'); @@ -51,6 +52,81 @@ export const PUT: Operation = [ insertUpdateSurveyObservations() ]; +const surveyObservationsResponseSchema: SchemaObject = { + title: 'Survey get response object, for view purposes', + type: 'object', + nullable: true, + required: ['surveyObservations'], + properties: { + surveyObservations: { + type: 'array', + items: { + type: 'object', + + required: [ + 'survey_observation_id', + 'wldtaxonomic_units_id', + 'latitude', + 'longitude', + 'count', + 'observation_date', + 'observation_time', + 'create_user', + 'create_date', + // 'update_user', + // 'update_date', + 'revision_count' + ], + properties: { + survey_observation_id: { + type: 'integer' + }, + wldtaxonomic_units_id: { + type: 'integer' + }, + latitude: { + type: 'number' + }, + longitude: { + type: 'number' + }, + count: { + type: 'integer' + }, + observation_date: { + type: 'string' + }, + observation_time: { + type: 'string' + }, + create_date: { + oneOf: [{ type: 'object' }, { type: 'string', format: 'date' }], + description: 'ISO 8601 date string for the project start date' + }, + create_user: { + type: 'integer', + minimum: 1 + }, + update_date: { + oneOf: [{ type: 'object' }, { type: 'string', format: 'date' }], + description: 'ISO 8601 date string for the project start date', + nullable: true + }, + update_user: { + type: 'integer', + minimum: 1, + nullable: true + }, + revision_count: { + type: 'integer', + minimum: 0 + } + } + } + } + } +} + GET.apiDoc = { description: 'Fetches observation records for the given survey.', tags: ['observation'], @@ -84,76 +160,7 @@ GET.apiDoc = { description: 'Survey Observations get response.', content: { 'application/json': { - schema: { - title: 'Survey get response object, for view purposes', - type: 'object', - nullable: true, - required: ['surveyObservations'], - properties: { - surveyObservations: { - type: 'object', - required: [ - 'survey_observation_id', - 'wldtaxonomic_units_id', - 'latitude', - 'longitude', - 'count', - 'observation_date', - 'observation_time', - 'create_user', - 'create_date', - 'update_user', - 'update_date', - 'revision_count' - ], - properties: { - survey_observation_id: { - type: 'integer' - }, - wldtaxonomic_units_id: { - type: 'integer' - }, - latitude: { - type: 'number' - }, - longitude: { - type: 'number' - }, - count: { - type: 'integer' - }, - observation_date: { - type: 'string' - }, - observation_time: { - type: 'string' - }, - create_date: { - oneOf: [{ type: 'object' }, { type: 'string', format: 'date' }], - description: 'ISO 8601 date string for the project start date' - }, - create_user: { - type: 'integer', - minimum: 1 - }, - update_date: { - oneOf: [{ type: 'object' }, { type: 'string', format: 'date' }], - description: 'ISO 8601 date string for the project start date', - nullable: true - }, - update_user: { - type: 'integer', - minimum: 1, - nullable: true - }, - revision_count: { - type: 'integer', - minimum: 0 - } - } - } - } - } + schema: { ...surveyObservationsResponseSchema } } } }, @@ -239,7 +246,9 @@ PUT.apiDoc = { 200: { description: 'Upload OK', content: { - 'application/json': {} + 'application/json': { + schema: { ...surveyObservationsResponseSchema } + } } }, 400: { @@ -328,9 +337,9 @@ export function insertUpdateSurveyObservations(): RequestHandler { }; }); - await observationService.insertUpdateSurveyObservations(surveyId, records); + const surveyObservations = await observationService.insertUpdateSurveyObservations(surveyId, records); - return res.status(200).send(); + return res.status(200).json({ surveyObservations }); } catch (error) { defaultLog.error({ label: 'insertUpdateSurveyObservations', message: 'error', error }); await connection.rollback(); From ef09b56b79b0882bd9354bc2f72f33fef2365127 Mon Sep 17 00:00:00 2001 From: Nick Phura Date: Tue, 26 Sep 2023 17:18:42 -0700 Subject: [PATCH 36/62] Fix taxonomy api types. Run formatting --- .../survey/{surveyId}/observation/index.ts | 5 +- .../repositories/observation-repository.ts | 46 +++++++++++-------- app/src/components/map/WFSFeatureGroup.tsx | 2 +- app/src/hooks/api/useTaxonomyApi.test.ts | 2 +- app/src/hooks/api/useTaxonomyApi.ts | 25 ++++++---- app/src/interfaces/useTaxonomy.interface.ts | 8 ++++ 6 files changed, 55 insertions(+), 33 deletions(-) create mode 100644 app/src/interfaces/useTaxonomy.interface.ts diff --git a/api/src/paths/project/{projectId}/survey/{surveyId}/observation/index.ts b/api/src/paths/project/{projectId}/survey/{surveyId}/observation/index.ts index c03668b26d..f282b3ef47 100644 --- a/api/src/paths/project/{projectId}/survey/{surveyId}/observation/index.ts +++ b/api/src/paths/project/{projectId}/survey/{surveyId}/observation/index.ts @@ -1,3 +1,4 @@ +import { SchemaObject } from 'ajv'; import { RequestHandler } from 'express'; import { Operation } from 'express-openapi'; import { PROJECT_PERMISSION, SYSTEM_ROLE } from '../../../../../../constants/roles'; @@ -6,7 +7,6 @@ import { InsertObservation, UpdateObservation } from '../../../../../../reposito import { authorizeRequestHandler } from '../../../../../../request-handlers/security/authorization'; import { ObservationService } from '../../../../../../services/observation-service'; import { getLogger } from '../../../../../../utils/logger'; -import { SchemaObject } from 'ajv'; const defaultLog = getLogger('/api/project/{projectId}/survey/{surveyId}/observation'); @@ -62,7 +62,6 @@ const surveyObservationsResponseSchema: SchemaObject = { type: 'array', items: { type: 'object', - required: [ 'survey_observation_id', 'wldtaxonomic_units_id', @@ -125,7 +124,7 @@ const surveyObservationsResponseSchema: SchemaObject = { } } } -} +}; GET.apiDoc = { description: 'Fetches observation records for the given survey.', diff --git a/api/src/repositories/observation-repository.ts b/api/src/repositories/observation-repository.ts index 0e61ecfc61..1fb1b8aee2 100644 --- a/api/src/repositories/observation-repository.ts +++ b/api/src/repositories/observation-repository.ts @@ -1,8 +1,8 @@ +import moment from 'moment'; +import SQL from 'sql-template-strings'; import { z } from 'zod'; import { getKnex } from '../database/db'; import { BaseRepository } from './base-repository'; -import SQL from 'sql-template-strings'; -import moment from 'moment'; export const ObservationRecord = z.object({ survey_observation_id: z.number(), @@ -26,7 +26,13 @@ export type InsertObservation = Pick< export type UpdateObservation = Pick< ObservationRecord, - 'survey_observation_id' | 'wldtaxonomic_units_id' | 'latitude' | 'longitude' | 'count' | 'observation_date' | 'observation_time' + | 'survey_observation_id' + | 'wldtaxonomic_units_id' + | 'latitude' + | 'longitude' + | 'count' + | 'observation_date' + | 'observation_time' >; export class ObservationRepository extends BaseRepository { @@ -42,7 +48,6 @@ export class ObservationRepository extends BaseRepository { surveyId: number, observations: (InsertObservation | UpdateObservation)[] ): Promise { - const query = SQL` INSERT INTO survey_observation @@ -59,19 +64,23 @@ export class ObservationRepository extends BaseRepository { OVERRIDING SYSTEM VALUE VALUES `; - - query.append(observations.map((observation) => { - return `(${[ - observation['survey_observation_id'] || 'DEFAULT', - surveyId, - observation.wldtaxonomic_units_id, - observation.count, - observation.latitude, - observation.longitude, - `'${moment(observation.observation_date).format('YYYY-MM-DD')}'`, - `'${observation.observation_time}'` - ].join(', ')})`; - }).join(', ')); + + query.append( + observations + .map((observation) => { + return `(${[ + observation['survey_observation_id'] || 'DEFAULT', + surveyId, + observation.wldtaxonomic_units_id, + observation.count, + observation.latitude, + observation.longitude, + `'${moment(observation.observation_date).format('YYYY-MM-DD')}'`, + `'${observation.observation_time}'` + ].join(', ')})`; + }) + .join(', ') + ); query.append(` ON CONFLICT @@ -84,7 +93,7 @@ export class ObservationRepository extends BaseRepository { latitude = EXCLUDED.latitude, longitude = EXCLUDED.longitude RETURNING *; - `) + `); /* console.log(query.text) @@ -103,7 +112,6 @@ export class ObservationRepository extends BaseRepository { */ return response.rows; - } /** diff --git a/app/src/components/map/WFSFeatureGroup.tsx b/app/src/components/map/WFSFeatureGroup.tsx index acd84b3082..6e33ff8160 100644 --- a/app/src/components/map/WFSFeatureGroup.tsx +++ b/app/src/components/map/WFSFeatureGroup.tsx @@ -69,7 +69,7 @@ const WFSFeatureGroup: React.FC = (props) => { const throttledSetBounds = useMemo( () => throttle((newBounds) => { - if (!isMounted) { + if (!isMounted()) { return; } diff --git a/app/src/hooks/api/useTaxonomyApi.test.ts b/app/src/hooks/api/useTaxonomyApi.test.ts index b4afb5419d..68fcd9f60b 100644 --- a/app/src/hooks/api/useTaxonomyApi.test.ts +++ b/app/src/hooks/api/useTaxonomyApi.test.ts @@ -46,7 +46,7 @@ describe('useTaxonomyApi', () => { mock.onGet('/api/taxonomy/species/list').reply(200, res); - const result = await useTaxonomyApi(axios).getSpeciesFromIds({ searchResponse: [1, 2] }); + const result = await useTaxonomyApi(axios).getSpeciesFromIds([1, 2]); expect(result).toEqual(res); }); diff --git a/app/src/hooks/api/useTaxonomyApi.ts b/app/src/hooks/api/useTaxonomyApi.ts index 8b016635e0..d5a9420595 100644 --- a/app/src/hooks/api/useTaxonomyApi.ts +++ b/app/src/hooks/api/useTaxonomyApi.ts @@ -1,19 +1,26 @@ import { AxiosInstance } from 'axios'; +import { ITaxonomySearchResult } from 'interfaces/useTaxonomy.interface'; import qs from 'qs'; -const useTaxonomyApi = (axios: AxiosInstance): any => { - const searchSpecies = async (value: string): Promise => { - axios.defaults.params = { terms: value }; - - const { data } = await axios.get(`/api/taxonomy/species/search`); +const useTaxonomyApi = (axios: AxiosInstance) => { + const searchSpecies = async (value: string): Promise => { + const { data } = await axios.get(`/api/taxonomy/species/search`, { + params: { terms: value }, + paramsSerializer: (params) => { + return qs.stringify(params); + } + }); return data; }; - const getSpeciesFromIds = async (value: number[]): Promise => { - axios.defaults.params = { ids: qs.stringify(value) }; - - const { data } = await axios.get(`/api/taxonomy/species/list`); + const getSpeciesFromIds = async (value: number[]): Promise => { + const { data } = await axios.get(`/api/taxonomy/species/list`, { + params: { ids: qs.stringify(value) }, + paramsSerializer: (params) => { + return qs.stringify(params); + } + }); return data; }; diff --git a/app/src/interfaces/useTaxonomy.interface.ts b/app/src/interfaces/useTaxonomy.interface.ts new file mode 100644 index 0000000000..8d80418c46 --- /dev/null +++ b/app/src/interfaces/useTaxonomy.interface.ts @@ -0,0 +1,8 @@ +export interface ITaxonomySearchResult { + searchResponse: ITaxonomy[]; +} + +export interface ITaxonomy { + id: string; + label: string; +} From fd35625a20a9be12aa69fdb9389a68c703257f50 Mon Sep 17 00:00:00 2001 From: Nick Phura Date: Tue, 26 Sep 2023 17:18:55 -0700 Subject: [PATCH 37/62] WIP datagrid autocomplete for taxonomy --- .../data-grid/SearchAutocompleteDataGrid.tsx | 154 ++++++++++++++++++ app/src/components/data-grid/TaxonomyCell.tsx | 38 +++++ .../data-grid/TaxonomyDataGridEditCell.tsx | 57 +++++++ .../observations/ObservationsTable.tsx | 10 +- 4 files changed, 258 insertions(+), 1 deletion(-) create mode 100644 app/src/components/data-grid/SearchAutocompleteDataGrid.tsx create mode 100644 app/src/components/data-grid/TaxonomyCell.tsx create mode 100644 app/src/components/data-grid/TaxonomyDataGridEditCell.tsx diff --git a/app/src/components/data-grid/SearchAutocompleteDataGrid.tsx b/app/src/components/data-grid/SearchAutocompleteDataGrid.tsx new file mode 100644 index 0000000000..27af6c31d2 --- /dev/null +++ b/app/src/components/data-grid/SearchAutocompleteDataGrid.tsx @@ -0,0 +1,154 @@ +import Autocomplete, { + AutocompleteChangeReason, + AutocompleteInputChangeReason, + createFilterOptions +} from '@mui/material/Autocomplete'; +import TextField from '@mui/material/TextField'; +import { GridRenderCellParams, GridValidRowModel } from '@mui/x-data-grid'; +import { DebouncedFunc } from 'lodash-es'; +import { useEffect, useState } from 'react'; + +export interface IAutocompleteFieldOption { + value: string | number; + label: string; +} + +export interface IAutocompleteField { + dataGridProps: GridRenderCellParams; + get: (value: string | number) => Promise; + search: DebouncedFunc< + (inputValue: string, callback: (searchResults: IAutocompleteFieldOption[]) => void) => Promise + >; + onChange: ( + _event: React.ChangeEvent, + selectedOption: IAutocompleteFieldOption | null, + reason: AutocompleteChangeReason + ) => void; +} + +const SearchAutocompleteDataGrid = (props: IAutocompleteField) => { + const [inputValue, setInputValue] = useState(''); + const [options, setOptions] = useState([]); + // const [selectedOption, setSelectedOption] = useState(null); + + const currentValue = props.dataGridProps.value; + + console.log(currentValue); + + const loadOptionsForCurrentValue = async () => { + const response = await props.get(currentValue); + + console.log('loadOptionsForCurrentValue', response); + + if (!response) { + return; + } + + setOptions([response]); + }; + + const searchAndUpdateOptions = async () => { + if (!inputValue) { + loadOptionsForCurrentValue(); + props.search.cancel(); + } else { + props.search(inputValue, (searchResults) => { + setOptions([...searchResults]); + }); + } + }; + + useEffect(() => { + loadOptionsForCurrentValue(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [currentValue]); + + useEffect(() => { + searchAndUpdateOptions(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [inputValue]); + + // const defaultHandleOnChange = (_event: React.ChangeEvent, selectedOption: IAutocompleteFieldOption[]) => { + // setOptions(sortAutocompleteOptions(selectedOption, options)); + // setSelectedOption(selectedOption); + // // setFieldValue( + // // props.id, + // // selectedOption.map((item) => item.value) + // // ); + // }; + + const handleGetOptionSelected = (option: IAutocompleteFieldOption, value: IAutocompleteFieldOption): boolean => { + if (!option?.value || !value?.value) { + return false; + } + + return option.value === value.value; + }; + + const handleOnInputChange = (event: React.ChangeEvent, value: string, reason: AutocompleteInputChangeReason) => { + if (event && event.type === 'blur') { + setInputValue(''); + } else if (reason !== 'reset') { + setInputValue(value); + } + }; + + const getExistingValue = (existingValue?: number | string): IAutocompleteFieldOption | null => { + console.log('getExistingValue', options); + if (existingValue) { + return options.find((option) => existingValue === option.value) || null; + } + + return null; + }; + + return ( + option.label} + isOptionEqualToValue={handleGetOptionSelected} + disableCloseOnSelect + disableListWrap + inputValue={inputValue} + onInputChange={handleOnInputChange} + onChange={props.onChange} + filterOptions={createFilterOptions({ limit: 50 })} + // renderOption={(renderProps, renderOption, { selected }) => { + // return ( + // + // } + // checkedIcon={} + // checked={selected} + // disabled={props.options.includes(renderOption) || false} + // value={renderOption.value} + // color="default" + // /> + // {renderOption.label} + // + // ); + // }} + renderInput={(params) => ( + { + if (event.key === 'Backspace') { + event.stopPropagation(); + } + }} + {...params} + variant="outlined" + fullWidth + placeholder="Type to start searching" + /> + )} + /> + ); +}; + +export default SearchAutocompleteDataGrid; diff --git a/app/src/components/data-grid/TaxonomyCell.tsx b/app/src/components/data-grid/TaxonomyCell.tsx new file mode 100644 index 0000000000..157c3f05ac --- /dev/null +++ b/app/src/components/data-grid/TaxonomyCell.tsx @@ -0,0 +1,38 @@ +import { GridRenderEditCellParams, GridValidRowModel } from '@mui/x-data-grid'; +import { useBiohubApi } from 'hooks/useBioHubApi'; +import useDataLoader from 'hooks/useDataLoader'; + +export interface ITaxonomyDataGridCellProps { + dataGridProps: GridRenderEditCellParams; +} + +/** + * Renders a data grid cell for a taxonomy value. + * + * Translates the raw taxonomy id into a human readable string. + * + * @template T + * @param {ITaxonomyDataGridCellProps} props + * @return {*} + */ +const TaxonomyDataGridCell = (props: ITaxonomyDataGridCellProps) => { + const { dataGridProps } = props; + + const biohubApi = useBiohubApi(); + + const taxonomyDataLoader = useDataLoader(() => biohubApi.taxonomy.getSpeciesFromIds([Number(dataGridProps.value)])); + + taxonomyDataLoader.load(); + + if (!taxonomyDataLoader.isReady) { + return null; + } + + if (taxonomyDataLoader.data?.searchResponse?.length !== 1) { + return null; + } + + return <>{taxonomyDataLoader.data?.searchResponse[0].label}; +}; + +export default TaxonomyDataGridCell; diff --git a/app/src/components/data-grid/TaxonomyDataGridEditCell.tsx b/app/src/components/data-grid/TaxonomyDataGridEditCell.tsx new file mode 100644 index 0000000000..77d895c9f6 --- /dev/null +++ b/app/src/components/data-grid/TaxonomyDataGridEditCell.tsx @@ -0,0 +1,57 @@ +import { GridRenderEditCellParams, GridValidRowModel, useGridApiContext } from '@mui/x-data-grid'; +import SearchAutocompleteDataGridCell, { + IAutocompleteFieldOption +} from 'components/data-grid/SearchAutocompleteDataGrid'; +import { useBiohubApi } from 'hooks/useBioHubApi'; +import debounce from 'lodash-es/debounce'; +import { useMemo } from 'react'; + +export interface ITaxonomyDataGridCellProps { + dataGridProps: GridRenderEditCellParams; +} + +const TaxonomyDataGridEditCell = (props: ITaxonomyDataGridCellProps) => { + const { dataGridProps } = props; + + const apiRef = useGridApiContext(); + + const biohubApi = useBiohubApi(); + + const handleGet = async (speciesId: string | number): Promise => { + const response = await biohubApi.taxonomy.getSpeciesFromIds([Number(speciesId)]); + + if (response.searchResponse.length !== 1) { + return null; + } + + return response.searchResponse.map((item) => ({ value: parseInt(item.id), label: item.label }))[0]; + }; + + const handleSearch = useMemo( + () => + debounce(async (inputValue: string, callback: (searchedValues: IAutocompleteFieldOption[]) => void) => { + const response = await biohubApi.taxonomy.searchSpecies(inputValue); + const newOptions = response.searchResponse.map((item) => ({ value: parseInt(item.id), label: item.label })); + callback(newOptions); + }, 500), + [biohubApi.taxonomy] + ); + + return ( + { + console.log('onChange', selectedOption); + apiRef.current.setEditCellValue({ + id: dataGridProps.id, + field: dataGridProps.field, + value: selectedOption?.value + }); + }} + /> + ); +}; + +export default TaxonomyDataGridEditCell; diff --git a/app/src/features/surveys/observations/ObservationsTable.tsx b/app/src/features/surveys/observations/ObservationsTable.tsx index 429a3b9dcc..45e788c915 100644 --- a/app/src/features/surveys/observations/ObservationsTable.tsx +++ b/app/src/features/surveys/observations/ObservationsTable.tsx @@ -2,6 +2,8 @@ import { mdiDotsVertical, mdiTrashCan } from '@mdi/js'; import Icon from '@mdi/react'; import IconButton from '@mui/material/IconButton'; import { DataGrid, GridColDef, GridEventListener, GridRowModelUpdate } from '@mui/x-data-grid'; +import TaxonomyDataGridCell from 'components/data-grid/TaxonomyCell'; +import TaxonomyDataGridEditCell from 'components/data-grid/TaxonomyDataGridEditCell'; import { fetchObservationDemoRows, IObservationTableRow, ObservationsContext } from 'contexts/observationsContext'; import useDataLoader from 'hooks/useDataLoader'; import { useContext, useEffect, useState } from 'react'; @@ -30,7 +32,13 @@ const ObservationsTable = (props: IObservationsTableProps) => { editable: true, flex: 1, minWidth: 250, - disableColumnMenu: true + disableColumnMenu: true, + renderCell: (params) => { + return ; + }, + renderEditCell: (params) => { + return ; + } }, { field: 'samplingSite', From c7593172587c30cd184803b07c2d2c143c2deef2 Mon Sep 17 00:00:00 2001 From: Curtis Upshall Date: Wed, 27 Sep 2023 09:20:45 -0700 Subject: [PATCH 38/62] SIMSBIOHUB-223: improved data fetching --- .../survey/{surveyId}/observation/index.ts | 10 ++++++---- .../repositories/observation-repository.ts | 20 +++++-------------- api/src/services/observation-service.ts | 4 ---- app/src/contexts/observationsContext.tsx | 17 ++++++++-------- .../observations/ObservationsTable.tsx | 16 ++++++++++----- app/src/hooks/api/useObservationApi.ts | 19 ++++++++++-------- 6 files changed, 41 insertions(+), 45 deletions(-) diff --git a/api/src/paths/project/{projectId}/survey/{surveyId}/observation/index.ts b/api/src/paths/project/{projectId}/survey/{surveyId}/observation/index.ts index c03668b26d..67210c48b6 100644 --- a/api/src/paths/project/{projectId}/survey/{surveyId}/observation/index.ts +++ b/api/src/paths/project/{projectId}/survey/{surveyId}/observation/index.ts @@ -73,8 +73,8 @@ const surveyObservationsResponseSchema: SchemaObject = { 'observation_time', 'create_user', 'create_date', - // 'update_user', - // 'update_date', + 'update_user', + 'update_date', 'revision_count' ], properties: { @@ -282,7 +282,7 @@ export function getSurveyObservations(): RequestHandler { const observationService = new ObservationService(connection); - const surveyObservations = observationService.getSurveyObservations(surveyId); + const surveyObservations = await observationService.getSurveyObservations(surveyId); return res.status(200).json({ surveyObservations }); } catch (error) { defaultLog.error({ label: 'getSurveyObservations', message: 'error', error }); @@ -308,6 +308,7 @@ export function insertUpdateSurveyObservations(): RequestHandler { const observationService = new ObservationService(connection); /* + // This code retrieves the taxonomic code for each table row const taxonomyService = new TaxonomyService(); const promises: Promise<(InsertObservation | UpdateObservation)>[] = req.body.map((record: any) => { return taxonomyService.searchSpecies(record.speciesName.toLowerCase()).then((taxonCodes) => ({ @@ -337,8 +338,9 @@ export function insertUpdateSurveyObservations(): RequestHandler { }; }); - const surveyObservations = await observationService.insertUpdateSurveyObservations(surveyId, records); + await observationService.insertUpdateSurveyObservations(surveyId, records); + const surveyObservations = await observationService.getSurveyObservations(surveyId); return res.status(200).json({ surveyObservations }); } catch (error) { defaultLog.error({ label: 'insertUpdateSurveyObservations', message: 'error', error }); diff --git a/api/src/repositories/observation-repository.ts b/api/src/repositories/observation-repository.ts index 0e61ecfc61..0416ef77a8 100644 --- a/api/src/repositories/observation-repository.ts +++ b/api/src/repositories/observation-repository.ts @@ -14,6 +14,9 @@ export const ObservationRecord = z.object({ observation_time: z.string(), observation_date: z.string(), create_date: z.string(), + create_user: z.number(), + update_date: z.string().nullable(), + update_user: z.number().nullable(), revision_count: z.number() }); @@ -83,25 +86,12 @@ export class ObservationRepository extends BaseRepository { observation_time = EXCLUDED.observation_time, latitude = EXCLUDED.latitude, longitude = EXCLUDED.longitude - RETURNING *; - `) + `); - /* - console.log(query.text) - console.log(query.values) - */ + query.append(`RETURNING *;`); const response = await this.connection.sql(query, ObservationRecord); - /* - // Not sure if this check is needed, as there may be certain cases where updates are idempotent - if (!response.rows.length) { - throw new ApiExecuteSQLError('Failed to insert/update survey observations', [ - 'ObservationRepository->insertUpdateSurveyObservations' - ]); - } - */ - return response.rows; } diff --git a/api/src/services/observation-service.ts b/api/src/services/observation-service.ts index 58a56a44b4..27bb7bab1d 100644 --- a/api/src/services/observation-service.ts +++ b/api/src/services/observation-service.ts @@ -5,11 +5,8 @@ import { ObservationRepository, UpdateObservation } from '../repositories/observation-repository'; -import { getLogger } from '../utils/logger'; import { DBService } from './db-service'; -const defaultLog = getLogger('services/observation-service'); - export class ObservationService extends DBService { observationRepository: ObservationRepository; @@ -30,7 +27,6 @@ export class ObservationService extends DBService { surveyId: number, observations: (InsertObservation | UpdateObservation)[] ): Promise { - defaultLog.debug({ label: 'insertUpdateObservationRecords' }); return this.observationRepository.insertUpdateSurveyObservations(surveyId, observations); } diff --git a/app/src/contexts/observationsContext.tsx b/app/src/contexts/observationsContext.tsx index fa13c6dd01..4187637c54 100644 --- a/app/src/contexts/observationsContext.tsx +++ b/app/src/contexts/observationsContext.tsx @@ -6,7 +6,7 @@ import { v4 as uuidv4 } from 'uuid'; import { SurveyContext } from './surveyContext'; export interface IObservationRecord { - observation_id: number | undefined; + survey_observation_id: number | undefined; speciesName: string | undefined; samplingSite: string | undefined; samplingMethod: string | undefined; @@ -22,12 +22,13 @@ export interface IObservationTableRow extends Partial { id: string; } +// TODO remove when finished testing export const fetchObservationDemoRows = async (): Promise => { return new Promise((resolve, reject) => { setTimeout(() => { resolve([ { - observation_id: 1, + survey_observation_id: 1, speciesName: 'Moose (Alces Americanus)', samplingSite: 'Site 1', samplingMethod: 'Method 1', @@ -39,7 +40,7 @@ export const fetchObservationDemoRows = async (): Promise longitude: 125 }, { - observation_id: 2, + survey_observation_id: 2, speciesName: 'Moose (Alces Americanus)', samplingSite: 'Site 1', samplingMethod: 'Method 1', @@ -51,7 +52,7 @@ export const fetchObservationDemoRows = async (): Promise longitude: 126 }, { - observation_id: 3, + survey_observation_id: 3, speciesName: 'Moose (Alces Americanus)', samplingSite: 'Site 1', samplingMethod: 'Method 1', @@ -75,7 +76,6 @@ export const fetchObservationDemoRows = async (): Promise */ export type IObservationsContext = { createNewRecord: () => void; - // getActiveRecords: () => IObservationTableRow[]; saveRecords: () => Promise; revertRecords: () => Promise; refreshRecords: () => Promise; @@ -85,7 +85,6 @@ export type IObservationsContext = { export const ObservationsContext = createContext({ _muiDataGridApiRef: { current: null as unknown as GridApiCommunity }, createNewRecord: () => {}, - // getActiveRecords: () => [], revertRecords: () => Promise.resolve(), saveRecords: () => Promise.resolve(), refreshRecords: () => Promise.resolve() @@ -108,7 +107,7 @@ export const ObservationsContextProvider = (props: PropsWithChildren { @@ -160,7 +160,6 @@ export const ObservationsContextProvider = (props: PropsWithChildren ({ const ObservationsTable = (props: IObservationsTableProps) => { // const classes = useStyles(); - const observationsDataLoader = useDataLoader(fetchObservationDemoRows); + const biohubApi = useBiohubApi(); + const { projectId, surveyId } = useContext(SurveyContext); + const observationsDataLoader = useDataLoader(() => biohubApi.observation.getObservationRecords(projectId, surveyId)); const [initialRows, setInitialRows] = useState([]); observationsDataLoader.load(); @@ -76,6 +80,7 @@ const ObservationsTable = (props: IObservationsTableProps) => { editable: true, type: 'date', minWidth: 150, + valueGetter: (params) => params.row.observation_date ? new Date(params.row.observation_date) : null, disableColumnMenu: true }, { @@ -124,10 +129,11 @@ const ObservationsTable = (props: IObservationsTableProps) => { useEffect(() => { if (observationsDataLoader.data) { - const rows: IObservationTableRow[] = observationsDataLoader.data.map((row) => ({ + const rows: IObservationTableRow[] = observationsDataLoader.data.map((row: IObservationTableRow) => ({ ...row, - id: String(row.observation_id), - _isModified: false + id: String(row.survey_observation_id), + // TODO map wldtaxonomic_units code to speciesName + // _isModified: false })); setInitialRows(rows); diff --git a/app/src/hooks/api/useObservationApi.ts b/app/src/hooks/api/useObservationApi.ts index 562650821b..feb8855833 100644 --- a/app/src/hooks/api/useObservationApi.ts +++ b/app/src/hooks/api/useObservationApi.ts @@ -185,18 +185,21 @@ const useObservationApi = (axios: AxiosInstance) => { projectId: number, surveyId: number, surveyObservations: IObservationTableRow[] - ) => { - const { data } = await axios.put(`/api/project/${projectId}/survey/${surveyId}/observation`, { - surveyObservations - }); + ): Promise => { + const { data } = await axios.put<{ surveyObservations: IObservationTableRow[] }>( + `/api/project/${projectId}/survey/${surveyId}/observation`, + { surveyObservations } + ); - return data; + return data.surveyObservations; }; - const getObservationRecords = async (projectId: number, surveyId: number) => { - const { data } = await axios.get(`/api/project/${projectId}/survey/${surveyId}/observation`); + const getObservationRecords = async (projectId: number, surveyId: number): Promise => { + const { data } = await axios.get<{ surveyObservations: IObservationTableRow[] }>( + `/api/project/${projectId}/survey/${surveyId}/observation` + ); - return data; + return data.surveyObservations; }; return { From 9202e09d204bb5686227d25e2acd798e692b0a64 Mon Sep 17 00:00:00 2001 From: Curtis Upshall Date: Wed, 27 Sep 2023 09:35:17 -0700 Subject: [PATCH 39/62] SIMSBIOHUB-223: Fix merge import error --- app/src/features/surveys/observations/ObservationsTable.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/app/src/features/surveys/observations/ObservationsTable.tsx b/app/src/features/surveys/observations/ObservationsTable.tsx index 4300785d78..953b7cfc7b 100644 --- a/app/src/features/surveys/observations/ObservationsTable.tsx +++ b/app/src/features/surveys/observations/ObservationsTable.tsx @@ -7,7 +7,6 @@ import { SurveyContext } from 'contexts/surveyContext'; import { useBiohubApi } from 'hooks/useBioHubApi'; import TaxonomyDataGridCell from 'components/data-grid/TaxonomyCell'; import TaxonomyDataGridEditCell from 'components/data-grid/TaxonomyDataGridEditCell'; -import { fetchObservationDemoRows, IObservationTableRow, ObservationsContext } from 'contexts/observationsContext'; import useDataLoader from 'hooks/useDataLoader'; import { useContext, useEffect, useState } from 'react'; // import { useEffect, useState } from "react"; From 3169a5a04f20beee952dcc3adb2d0fac906f6c4a Mon Sep 17 00:00:00 2001 From: Nick Phura Date: Wed, 27 Sep 2023 10:12:52 -0700 Subject: [PATCH 40/62] Remove WIP taxonomy autocomplete components --- .../data-grid/SearchAutocompleteDataGrid.tsx | 154 ------------------ app/src/components/data-grid/TaxonomyCell.tsx | 38 ----- .../data-grid/TaxonomyDataGridEditCell.tsx | 57 ------- .../observations/ObservationsTable.tsx | 15 +- 4 files changed, 3 insertions(+), 261 deletions(-) delete mode 100644 app/src/components/data-grid/SearchAutocompleteDataGrid.tsx delete mode 100644 app/src/components/data-grid/TaxonomyCell.tsx delete mode 100644 app/src/components/data-grid/TaxonomyDataGridEditCell.tsx diff --git a/app/src/components/data-grid/SearchAutocompleteDataGrid.tsx b/app/src/components/data-grid/SearchAutocompleteDataGrid.tsx deleted file mode 100644 index 27af6c31d2..0000000000 --- a/app/src/components/data-grid/SearchAutocompleteDataGrid.tsx +++ /dev/null @@ -1,154 +0,0 @@ -import Autocomplete, { - AutocompleteChangeReason, - AutocompleteInputChangeReason, - createFilterOptions -} from '@mui/material/Autocomplete'; -import TextField from '@mui/material/TextField'; -import { GridRenderCellParams, GridValidRowModel } from '@mui/x-data-grid'; -import { DebouncedFunc } from 'lodash-es'; -import { useEffect, useState } from 'react'; - -export interface IAutocompleteFieldOption { - value: string | number; - label: string; -} - -export interface IAutocompleteField { - dataGridProps: GridRenderCellParams; - get: (value: string | number) => Promise; - search: DebouncedFunc< - (inputValue: string, callback: (searchResults: IAutocompleteFieldOption[]) => void) => Promise - >; - onChange: ( - _event: React.ChangeEvent, - selectedOption: IAutocompleteFieldOption | null, - reason: AutocompleteChangeReason - ) => void; -} - -const SearchAutocompleteDataGrid = (props: IAutocompleteField) => { - const [inputValue, setInputValue] = useState(''); - const [options, setOptions] = useState([]); - // const [selectedOption, setSelectedOption] = useState(null); - - const currentValue = props.dataGridProps.value; - - console.log(currentValue); - - const loadOptionsForCurrentValue = async () => { - const response = await props.get(currentValue); - - console.log('loadOptionsForCurrentValue', response); - - if (!response) { - return; - } - - setOptions([response]); - }; - - const searchAndUpdateOptions = async () => { - if (!inputValue) { - loadOptionsForCurrentValue(); - props.search.cancel(); - } else { - props.search(inputValue, (searchResults) => { - setOptions([...searchResults]); - }); - } - }; - - useEffect(() => { - loadOptionsForCurrentValue(); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [currentValue]); - - useEffect(() => { - searchAndUpdateOptions(); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [inputValue]); - - // const defaultHandleOnChange = (_event: React.ChangeEvent, selectedOption: IAutocompleteFieldOption[]) => { - // setOptions(sortAutocompleteOptions(selectedOption, options)); - // setSelectedOption(selectedOption); - // // setFieldValue( - // // props.id, - // // selectedOption.map((item) => item.value) - // // ); - // }; - - const handleGetOptionSelected = (option: IAutocompleteFieldOption, value: IAutocompleteFieldOption): boolean => { - if (!option?.value || !value?.value) { - return false; - } - - return option.value === value.value; - }; - - const handleOnInputChange = (event: React.ChangeEvent, value: string, reason: AutocompleteInputChangeReason) => { - if (event && event.type === 'blur') { - setInputValue(''); - } else if (reason !== 'reset') { - setInputValue(value); - } - }; - - const getExistingValue = (existingValue?: number | string): IAutocompleteFieldOption | null => { - console.log('getExistingValue', options); - if (existingValue) { - return options.find((option) => existingValue === option.value) || null; - } - - return null; - }; - - return ( - option.label} - isOptionEqualToValue={handleGetOptionSelected} - disableCloseOnSelect - disableListWrap - inputValue={inputValue} - onInputChange={handleOnInputChange} - onChange={props.onChange} - filterOptions={createFilterOptions({ limit: 50 })} - // renderOption={(renderProps, renderOption, { selected }) => { - // return ( - // - // } - // checkedIcon={} - // checked={selected} - // disabled={props.options.includes(renderOption) || false} - // value={renderOption.value} - // color="default" - // /> - // {renderOption.label} - // - // ); - // }} - renderInput={(params) => ( - { - if (event.key === 'Backspace') { - event.stopPropagation(); - } - }} - {...params} - variant="outlined" - fullWidth - placeholder="Type to start searching" - /> - )} - /> - ); -}; - -export default SearchAutocompleteDataGrid; diff --git a/app/src/components/data-grid/TaxonomyCell.tsx b/app/src/components/data-grid/TaxonomyCell.tsx deleted file mode 100644 index 157c3f05ac..0000000000 --- a/app/src/components/data-grid/TaxonomyCell.tsx +++ /dev/null @@ -1,38 +0,0 @@ -import { GridRenderEditCellParams, GridValidRowModel } from '@mui/x-data-grid'; -import { useBiohubApi } from 'hooks/useBioHubApi'; -import useDataLoader from 'hooks/useDataLoader'; - -export interface ITaxonomyDataGridCellProps { - dataGridProps: GridRenderEditCellParams; -} - -/** - * Renders a data grid cell for a taxonomy value. - * - * Translates the raw taxonomy id into a human readable string. - * - * @template T - * @param {ITaxonomyDataGridCellProps} props - * @return {*} - */ -const TaxonomyDataGridCell = (props: ITaxonomyDataGridCellProps) => { - const { dataGridProps } = props; - - const biohubApi = useBiohubApi(); - - const taxonomyDataLoader = useDataLoader(() => biohubApi.taxonomy.getSpeciesFromIds([Number(dataGridProps.value)])); - - taxonomyDataLoader.load(); - - if (!taxonomyDataLoader.isReady) { - return null; - } - - if (taxonomyDataLoader.data?.searchResponse?.length !== 1) { - return null; - } - - return <>{taxonomyDataLoader.data?.searchResponse[0].label}; -}; - -export default TaxonomyDataGridCell; diff --git a/app/src/components/data-grid/TaxonomyDataGridEditCell.tsx b/app/src/components/data-grid/TaxonomyDataGridEditCell.tsx deleted file mode 100644 index 77d895c9f6..0000000000 --- a/app/src/components/data-grid/TaxonomyDataGridEditCell.tsx +++ /dev/null @@ -1,57 +0,0 @@ -import { GridRenderEditCellParams, GridValidRowModel, useGridApiContext } from '@mui/x-data-grid'; -import SearchAutocompleteDataGridCell, { - IAutocompleteFieldOption -} from 'components/data-grid/SearchAutocompleteDataGrid'; -import { useBiohubApi } from 'hooks/useBioHubApi'; -import debounce from 'lodash-es/debounce'; -import { useMemo } from 'react'; - -export interface ITaxonomyDataGridCellProps { - dataGridProps: GridRenderEditCellParams; -} - -const TaxonomyDataGridEditCell = (props: ITaxonomyDataGridCellProps) => { - const { dataGridProps } = props; - - const apiRef = useGridApiContext(); - - const biohubApi = useBiohubApi(); - - const handleGet = async (speciesId: string | number): Promise => { - const response = await biohubApi.taxonomy.getSpeciesFromIds([Number(speciesId)]); - - if (response.searchResponse.length !== 1) { - return null; - } - - return response.searchResponse.map((item) => ({ value: parseInt(item.id), label: item.label }))[0]; - }; - - const handleSearch = useMemo( - () => - debounce(async (inputValue: string, callback: (searchedValues: IAutocompleteFieldOption[]) => void) => { - const response = await biohubApi.taxonomy.searchSpecies(inputValue); - const newOptions = response.searchResponse.map((item) => ({ value: parseInt(item.id), label: item.label })); - callback(newOptions); - }, 500), - [biohubApi.taxonomy] - ); - - return ( - { - console.log('onChange', selectedOption); - apiRef.current.setEditCellValue({ - id: dataGridProps.id, - field: dataGridProps.field, - value: selectedOption?.value - }); - }} - /> - ); -}; - -export default TaxonomyDataGridEditCell; diff --git a/app/src/features/surveys/observations/ObservationsTable.tsx b/app/src/features/surveys/observations/ObservationsTable.tsx index 4300785d78..9e0b4dec7b 100644 --- a/app/src/features/surveys/observations/ObservationsTable.tsx +++ b/app/src/features/surveys/observations/ObservationsTable.tsx @@ -5,9 +5,6 @@ import { DataGrid, GridColDef, GridEventListener, GridRowModelUpdate } from '@mu import { IObservationTableRow, ObservationsContext } from 'contexts/observationsContext'; import { SurveyContext } from 'contexts/surveyContext'; import { useBiohubApi } from 'hooks/useBioHubApi'; -import TaxonomyDataGridCell from 'components/data-grid/TaxonomyCell'; -import TaxonomyDataGridEditCell from 'components/data-grid/TaxonomyDataGridEditCell'; -import { fetchObservationDemoRows, IObservationTableRow, ObservationsContext } from 'contexts/observationsContext'; import useDataLoader from 'hooks/useDataLoader'; import { useContext, useEffect, useState } from 'react'; // import { useEffect, useState } from "react"; @@ -37,13 +34,7 @@ const ObservationsTable = (props: IObservationsTableProps) => { editable: true, flex: 1, minWidth: 250, - disableColumnMenu: true, - renderCell: (params) => { - return ; - }, - renderEditCell: (params) => { - return ; - } + disableColumnMenu: true }, { field: 'samplingSite', @@ -89,7 +80,7 @@ const ObservationsTable = (props: IObservationsTableProps) => { editable: true, type: 'date', minWidth: 150, - valueGetter: (params) => params.row.observation_date ? new Date(params.row.observation_date) : null, + valueGetter: (params) => (params.row.observation_date ? new Date(params.row.observation_date) : null), disableColumnMenu: true }, { @@ -140,7 +131,7 @@ const ObservationsTable = (props: IObservationsTableProps) => { if (observationsDataLoader.data) { const rows: IObservationTableRow[] = observationsDataLoader.data.map((row: IObservationTableRow) => ({ ...row, - id: String(row.survey_observation_id), + id: String(row.survey_observation_id) // TODO map wldtaxonomic_units code to speciesName // _isModified: false })); From 5c34a1f8bf581ba47b03597b2eb2683e39702384 Mon Sep 17 00:00:00 2001 From: Curtis Upshall Date: Wed, 27 Sep 2023 10:26:07 -0700 Subject: [PATCH 41/62] SIMSBIOHUB-223: Temp remove specieName --- .../survey/{surveyId}/observation/index.ts | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/api/src/paths/project/{projectId}/survey/{surveyId}/observation/index.ts b/api/src/paths/project/{projectId}/survey/{surveyId}/observation/index.ts index 18aceab926..57aac123a4 100644 --- a/api/src/paths/project/{projectId}/survey/{surveyId}/observation/index.ts +++ b/api/src/paths/project/{projectId}/survey/{surveyId}/observation/index.ts @@ -213,11 +213,20 @@ PUT.apiDoc = { type: 'array', items: { type: 'object', - required: ['speciesName', 'count', 'latitude', 'longitude', 'observation_date', 'observation_time'], + required: [ + // 'speciesName', // TODO: this won't remain optional. We'll likely be dircetly passing the wldtaxonomic units code. + 'count', + 'latitude', + 'longitude', + 'observation_date', + 'observation_time' + ], properties: { + /* speciesName: { type: 'string' }, + */ count: { type: 'number' }, From 6871c3f50ec0888d78d36851ebcd587e28346151 Mon Sep 17 00:00:00 2001 From: Curtis Upshall Date: Wed, 27 Sep 2023 11:32:39 -0700 Subject: [PATCH 42/62] SIMSBIOHUB-223: Working record updating --- .../survey/{surveyId}/observation/index.ts | 4 +++- app/src/contexts/observationsContext.tsx | 23 +++++++++++-------- .../observations/ObservationsTable.tsx | 3 +++ 3 files changed, 19 insertions(+), 11 deletions(-) diff --git a/api/src/paths/project/{projectId}/survey/{surveyId}/observation/index.ts b/api/src/paths/project/{projectId}/survey/{surveyId}/observation/index.ts index 57aac123a4..c900600a9e 100644 --- a/api/src/paths/project/{projectId}/survey/{surveyId}/observation/index.ts +++ b/api/src/paths/project/{projectId}/survey/{surveyId}/observation/index.ts @@ -347,8 +347,10 @@ export function insertUpdateSurveyObservations(): RequestHandler { }); await observationService.insertUpdateSurveyObservations(surveyId, records); - const surveyObservations = await observationService.getSurveyObservations(surveyId); + + await connection.commit(); + return res.status(200).json({ surveyObservations }); } catch (error) { defaultLog.error({ label: 'insertUpdateSurveyObservations', message: 'error', error }); diff --git a/app/src/contexts/observationsContext.tsx b/app/src/contexts/observationsContext.tsx index 4187637c54..48fb1a7f7f 100644 --- a/app/src/contexts/observationsContext.tsx +++ b/app/src/contexts/observationsContext.tsx @@ -95,12 +95,6 @@ export const ObservationsContextProvider = (props: PropsWithChildren { - return Array.from(_muiDataGridApiRef.current.getRowModels?.()?.values()) as IObservationTableRow[] - } - */ - const createNewRecord = () => { const id = uuidv4(); @@ -123,7 +117,10 @@ export const ObservationsContextProvider = (props: PropsWithChildren { + return Array.from(_muiDataGridApiRef.current.getRowModels?.()?.values()) as IObservationTableRow[] + } + const getActiveRecords = (): IObservationTableRow[] => { return _getRows().map((row) => { const editRow = _muiDataGridApiRef.current.state.editRows[row.id] @@ -136,17 +133,23 @@ export const ObservationsContextProvider = (props: PropsWithChildren ({ ...row, ...newRow, _isModified: true, [entry[0]]: entry[1].value }), {}); }) as IObservationTableRow[]; } - */ + const saveRecords = async () => { const editingIds = Object.keys(_muiDataGridApiRef.current.state.editRows); editingIds.forEach((id) => _muiDataGridApiRef.current.stopRowEditMode({ id })); const { projectId, surveyId } = surveyContext; - const rows = Array.from(_muiDataGridApiRef.current.getRowModels?.()?.values()) as IObservationTableRow[]; + + const rows = getActiveRecords() // _getRows() const updatedRows = await biohubApi.observation.insertUpdateObservationRecords(projectId, surveyId, rows); - _muiDataGridApiRef.current.setRows(updatedRows); + _muiDataGridApiRef.current.setRows(updatedRows.map((row: IObservationTableRow) => ({ + ...row, + id: String(row.survey_observation_id) + // TODO map wldtaxonomic_units code to speciesName + // _isModified: false + }))); }; const revertRecords = async () => { diff --git a/app/src/features/surveys/observations/ObservationsTable.tsx b/app/src/features/surveys/observations/ObservationsTable.tsx index 9e0b4dec7b..24a78427e1 100644 --- a/app/src/features/surveys/observations/ObservationsTable.tsx +++ b/app/src/features/surveys/observations/ObservationsTable.tsx @@ -22,6 +22,9 @@ const ObservationsTable = (props: IObservationsTableProps) => { // const classes = useStyles(); const biohubApi = useBiohubApi(); const { projectId, surveyId } = useContext(SurveyContext); + + // TODO: in the near future, we may want to move observationsDataLoader into the ObservationContext, + // in order to avoid having to map the API response values const observationsDataLoader = useDataLoader(() => biohubApi.observation.getObservationRecords(projectId, surveyId)); const [initialRows, setInitialRows] = useState([]); From 188b7d76a212ebf5db41c2d7b54b8c1cb2e32644 Mon Sep 17 00:00:00 2001 From: Curtis Upshall Date: Wed, 27 Sep 2023 12:19:06 -0700 Subject: [PATCH 43/62] SIMSBIOHUB-223: Replace speciesName with wldtaxonomic_units --- .../survey/{surveyId}/observation/index.ts | 26 +++---------------- app/src/contexts/observationsContext.tsx | 14 +++++----- .../observations/ObservationsTable.tsx | 16 +++--------- 3 files changed, 12 insertions(+), 44 deletions(-) diff --git a/api/src/paths/project/{projectId}/survey/{surveyId}/observation/index.ts b/api/src/paths/project/{projectId}/survey/{surveyId}/observation/index.ts index c900600a9e..63d0654cf6 100644 --- a/api/src/paths/project/{projectId}/survey/{surveyId}/observation/index.ts +++ b/api/src/paths/project/{projectId}/survey/{surveyId}/observation/index.ts @@ -214,7 +214,7 @@ PUT.apiDoc = { items: { type: 'object', required: [ - // 'speciesName', // TODO: this won't remain optional. We'll likely be dircetly passing the wldtaxonomic units code. + 'wldtaxonomic_units_id', 'count', 'latitude', 'longitude', @@ -222,11 +222,9 @@ PUT.apiDoc = { 'observation_time' ], properties: { - /* - speciesName: { - type: 'string' + wldtaxonomic_units_id: { + type: 'integer' }, - */ count: { type: 'number' }, @@ -315,24 +313,6 @@ export function insertUpdateSurveyObservations(): RequestHandler { const observationService = new ObservationService(connection); - /* - // This code retrieves the taxonomic code for each table row - const taxonomyService = new TaxonomyService(); - const promises: Promise<(InsertObservation | UpdateObservation)>[] = req.body.map((record: any) => { - return taxonomyService.searchSpecies(record.speciesName.toLowerCase()).then((taxonCodes) => ({ - survey_id: surveyId, - survey_observation_id: record.survey_observation_id, - wldtaxonomic_units_id: taxonCodes[0].id, - latitude: record.latitude, - longitude: record.longitude, - count: record.count, - observation_date: record.observation_date, - observation_time: record.observation_time - })) - }); - const records = await Promise.all(promises); - */ - const records: (InsertObservation | UpdateObservation)[] = req.body.surveyObservations.map((record: any) => { return { survey_id: surveyId, diff --git a/app/src/contexts/observationsContext.tsx b/app/src/contexts/observationsContext.tsx index 48fb1a7f7f..00454e7a6c 100644 --- a/app/src/contexts/observationsContext.tsx +++ b/app/src/contexts/observationsContext.tsx @@ -7,7 +7,7 @@ import { SurveyContext } from './surveyContext'; export interface IObservationRecord { survey_observation_id: number | undefined; - speciesName: string | undefined; + wldtaxonomic_units_id: number | undefined; samplingSite: string | undefined; samplingMethod: string | undefined; samplingPeriod: string | undefined; @@ -29,7 +29,7 @@ export const fetchObservationDemoRows = async (): Promise resolve([ { survey_observation_id: 1, - speciesName: 'Moose (Alces Americanus)', + wldtaxonomic_units_id: 1111, samplingSite: 'Site 1', samplingMethod: 'Method 1', samplingPeriod: undefined, @@ -41,7 +41,7 @@ export const fetchObservationDemoRows = async (): Promise }, { survey_observation_id: 2, - speciesName: 'Moose (Alces Americanus)', + wldtaxonomic_units_id: 1111, samplingSite: 'Site 1', samplingMethod: 'Method 1', samplingPeriod: undefined, @@ -53,7 +53,7 @@ export const fetchObservationDemoRows = async (): Promise }, { survey_observation_id: 3, - speciesName: 'Moose (Alces Americanus)', + wldtaxonomic_units_id: 1111, samplingSite: 'Site 1', samplingMethod: 'Method 1', samplingPeriod: undefined, @@ -102,7 +102,7 @@ export const ObservationsContextProvider = (props: PropsWithChildren { @@ -147,8 +147,6 @@ export const ObservationsContextProvider = (props: PropsWithChildren ({ ...row, id: String(row.survey_observation_id) - // TODO map wldtaxonomic_units code to speciesName - // _isModified: false }))); }; diff --git a/app/src/features/surveys/observations/ObservationsTable.tsx b/app/src/features/surveys/observations/ObservationsTable.tsx index 24a78427e1..4464c7ad17 100644 --- a/app/src/features/surveys/observations/ObservationsTable.tsx +++ b/app/src/features/surveys/observations/ObservationsTable.tsx @@ -7,19 +7,10 @@ import { SurveyContext } from 'contexts/surveyContext'; import { useBiohubApi } from 'hooks/useBioHubApi'; import useDataLoader from 'hooks/useDataLoader'; import { useContext, useEffect, useState } from 'react'; -// import { useEffect, useState } from "react"; -// import { pluralize as p } from "utils/Utils"; export type IObservationsTableProps = Record; -/* -const useStyles = makeStyles((theme: Theme) => ({ - modifiedRow: {} // { background: 'rgba(65, 168, 3, 0.16)' } -})); -*/ - const ObservationsTable = (props: IObservationsTableProps) => { - // const classes = useStyles(); const biohubApi = useBiohubApi(); const { projectId, surveyId } = useContext(SurveyContext); @@ -32,12 +23,13 @@ const ObservationsTable = (props: IObservationsTableProps) => { const observationColumns: GridColDef[] = [ { - field: 'speciesName', + field: 'wldtaxonomic_units_id', headerName: 'Species', editable: true, flex: 1, minWidth: 250, - disableColumnMenu: true + disableColumnMenu: true, + renderCell: () => 'Moose (Alces Americanus)' }, { field: 'samplingSite', @@ -135,8 +127,6 @@ const ObservationsTable = (props: IObservationsTableProps) => { const rows: IObservationTableRow[] = observationsDataLoader.data.map((row: IObservationTableRow) => ({ ...row, id: String(row.survey_observation_id) - // TODO map wldtaxonomic_units code to speciesName - // _isModified: false })); setInitialRows(rows); From b85728c77f1f2c1a2b5f9dea336c1627d8ff394f Mon Sep 17 00:00:00 2001 From: Curtis Upshall Date: Wed, 27 Sep 2023 12:26:57 -0700 Subject: [PATCH 44/62] SIMSBIOHUB-223: Removed temp demo fetching; fixed observation_time typo in dgrid --- app/src/contexts/observationsContext.tsx | 46 ------------------- .../observations/ObservationsTable.tsx | 2 +- app/src/hooks/api/useObservationApi.ts | 9 +++- 3 files changed, 9 insertions(+), 48 deletions(-) diff --git a/app/src/contexts/observationsContext.tsx b/app/src/contexts/observationsContext.tsx index 00454e7a6c..04371f6110 100644 --- a/app/src/contexts/observationsContext.tsx +++ b/app/src/contexts/observationsContext.tsx @@ -22,52 +22,6 @@ export interface IObservationTableRow extends Partial { id: string; } -// TODO remove when finished testing -export const fetchObservationDemoRows = async (): Promise => { - return new Promise((resolve, reject) => { - setTimeout(() => { - resolve([ - { - survey_observation_id: 1, - wldtaxonomic_units_id: 1111, - samplingSite: 'Site 1', - samplingMethod: 'Method 1', - samplingPeriod: undefined, - count: 1, - observation_date: new Date('2020-01-01'), - observation_time: '12:00:00', - latitude: 45, - longitude: 125 - }, - { - survey_observation_id: 2, - wldtaxonomic_units_id: 1111, - samplingSite: 'Site 1', - samplingMethod: 'Method 1', - samplingPeriod: undefined, - count: 2, - observation_date: new Date('2021-01-01'), - observation_time: '13:00:00', - latitude: 46, - longitude: 126 - }, - { - survey_observation_id: 3, - wldtaxonomic_units_id: 1111, - samplingSite: 'Site 1', - samplingMethod: 'Method 1', - samplingPeriod: undefined, - count: 3, - observation_date: new Date('2022-01-01'), - observation_time: '14:00:00', - latitude: 47, - longitude: 127 - } - ]); - }, 1000 * (Math.random() + 1)); - }); -}; - /** * Context object that stores information about survey observations * diff --git a/app/src/features/surveys/observations/ObservationsTable.tsx b/app/src/features/surveys/observations/ObservationsTable.tsx index 4464c7ad17..53bd71df9b 100644 --- a/app/src/features/surveys/observations/ObservationsTable.tsx +++ b/app/src/features/surveys/observations/ObservationsTable.tsx @@ -79,7 +79,7 @@ const ObservationsTable = (props: IObservationsTableProps) => { disableColumnMenu: true }, { - field: 'observation_date', + field: 'observation_time', headerName: 'Time', editable: true, type: 'time', diff --git a/app/src/hooks/api/useObservationApi.ts b/app/src/hooks/api/useObservationApi.ts index feb8855833..5039ee07e4 100644 --- a/app/src/hooks/api/useObservationApi.ts +++ b/app/src/hooks/api/useObservationApi.ts @@ -174,7 +174,7 @@ const useObservationApi = (axios: AxiosInstance) => { }; /** - * TODO + * Insert/updates all survey observation records for the given survey * * @param {number} projectId * @param {number} surveyId @@ -194,6 +194,13 @@ const useObservationApi = (axios: AxiosInstance) => { return data.surveyObservations; }; + /** + * Retrieves all survey observation records for the given survey + * + * @param {number} projectId + * @param {number} surveyId + * @return {*} {Promise} + */ const getObservationRecords = async (projectId: number, surveyId: number): Promise => { const { data } = await axios.get<{ surveyObservations: IObservationTableRow[] }>( `/api/project/${projectId}/survey/${surveyId}/observation` From 5736615be85669763403c1814378fabfddd34f6d Mon Sep 17 00:00:00 2001 From: Curtis Upshall Date: Wed, 27 Sep 2023 13:10:58 -0700 Subject: [PATCH 45/62] SIMSBIOHUB-223: Working record creation and subsequent updating --- .../survey/{surveyId}/observation/index.ts | 7 ++- app/src/contexts/observationsContext.tsx | 17 ++++++- .../observations/ObservationsTable.tsx | 46 +++++-------------- app/src/hooks/api/useObservationApi.ts | 9 ++-- .../interfaces/useObservationApi.interface.ts | 5 ++ 5 files changed, 43 insertions(+), 41 deletions(-) diff --git a/api/src/paths/project/{projectId}/survey/{surveyId}/observation/index.ts b/api/src/paths/project/{projectId}/survey/{surveyId}/observation/index.ts index 63d0654cf6..9b9f0c03db 100644 --- a/api/src/paths/project/{projectId}/survey/{surveyId}/observation/index.ts +++ b/api/src/paths/project/{projectId}/survey/{surveyId}/observation/index.ts @@ -223,7 +223,10 @@ PUT.apiDoc = { ], properties: { wldtaxonomic_units_id: { - type: 'integer' + oneOf: [ + { type: 'integer' }, + { type: 'string' } + ] }, count: { type: 'number' @@ -317,7 +320,7 @@ export function insertUpdateSurveyObservations(): RequestHandler { return { survey_id: surveyId, survey_observation_id: record.survey_observation_id, - wldtaxonomic_units_id: 1234, + wldtaxonomic_units_id: Number(record.wldtaxonomic_units_id), latitude: record.latitude, longitude: record.longitude, count: record.count, diff --git a/app/src/contexts/observationsContext.tsx b/app/src/contexts/observationsContext.tsx index 04371f6110..a05ee0abf1 100644 --- a/app/src/contexts/observationsContext.tsx +++ b/app/src/contexts/observationsContext.tsx @@ -4,6 +4,8 @@ import { useBiohubApi } from 'hooks/useBioHubApi'; import { createContext, PropsWithChildren, useContext } from 'react'; import { v4 as uuidv4 } from 'uuid'; import { SurveyContext } from './surveyContext'; +import useDataLoader, { DataLoader } from 'hooks/useDataLoader'; +import { IGetSurveyObservationsResponse } from 'interfaces/useObservationApi.interface'; export interface IObservationRecord { survey_observation_id: number | undefined; @@ -33,11 +35,13 @@ export type IObservationsContext = { saveRecords: () => Promise; revertRecords: () => Promise; refreshRecords: () => Promise; + observationsDataLoader: DataLoader<[], IGetSurveyObservationsResponse, unknown> _muiDataGridApiRef: React.MutableRefObject; }; export const ObservationsContext = createContext({ _muiDataGridApiRef: { current: null as unknown as GridApiCommunity }, + observationsDataLoader: {} as DataLoader, createNewRecord: () => {}, revertRecords: () => Promise.resolve(), saveRecords: () => Promise.resolve(), @@ -48,6 +52,10 @@ export const ObservationsContextProvider = (props: PropsWithChildren biohubApi.observation.getObservationRecords(projectId, surveyId)); + + observationsDataLoader.load(); const createNewRecord = () => { const id = uuidv4(); @@ -97,11 +105,17 @@ export const ObservationsContextProvider = (props: PropsWithChildren ({ ...row, id: String(row.survey_observation_id) }))); + */ + + observationsDataLoader.refresh(); }; const revertRecords = async () => { @@ -118,6 +132,7 @@ export const ObservationsContextProvider = (props: PropsWithChildren; -const ObservationsTable = (props: IObservationsTableProps) => { - const biohubApi = useBiohubApi(); - const { projectId, surveyId } = useContext(SurveyContext); - - // TODO: in the near future, we may want to move observationsDataLoader into the ObservationContext, - // in order to avoid having to map the API response values - const observationsDataLoader = useDataLoader(() => biohubApi.observation.getObservationRecords(projectId, surveyId)); - const [initialRows, setInitialRows] = useState([]); - - observationsDataLoader.load(); +const ObservationsTable = (props: IObservationsTableProps) => { + const [initialRows, setInitialRows] = useState([]); const observationColumns: GridColDef[] = [ { @@ -120,11 +109,13 @@ const ObservationsTable = (props: IObservationsTableProps) => { } ]; - const apiRef = useContext(ObservationsContext)._muiDataGridApiRef; + const observationsContext = useContext(ObservationsContext); + const { observationsDataLoader } = observationsContext; + const apiRef = observationsContext._muiDataGridApiRef; useEffect(() => { - if (observationsDataLoader.data) { - const rows: IObservationTableRow[] = observationsDataLoader.data.map((row: IObservationTableRow) => ({ + if (observationsDataLoader.data?.surveyObservations) { + const rows: IObservationTableRow[] = observationsDataLoader.data.surveyObservations.map((row: IObservationTableRow) => ({ ...row, id: String(row.survey_observation_id) })); @@ -149,36 +140,23 @@ const ObservationsTable = (props: IObservationsTableProps) => { apiRef.current.startRowEditMode({ id: params.row.id, fieldToFocus: params.field }); }; - /* - const modifiedKeys = new Set([ - ...Object.keys(apiRef.current.state.editRows), - ...apiRef.current.get - ]); - */ + const handleProcessRowUpdate = (newRow: IObservationTableRow) => { + const updatedRow = { ...newRow, wldtaxonomic_units_id: Number(newRow.wldtaxonomic_units_id) }; + return updatedRow; + }; return ( { - return [ - numSelected > 0 && `${numSelected} ${p(numSelected, 'row')} selected`, - numModified > 0 && `${numModified} unsaved ${p(numModified, 'row')}` - ].filter(Boolean).join(', ') - } - */ }} sx={{ background: '#fff', diff --git a/app/src/hooks/api/useObservationApi.ts b/app/src/hooks/api/useObservationApi.ts index 5039ee07e4..6fc4ce3a11 100644 --- a/app/src/hooks/api/useObservationApi.ts +++ b/app/src/hooks/api/useObservationApi.ts @@ -3,6 +3,7 @@ import { IObservationTableRow } from 'contexts/observationsContext'; import { GeoJsonProperties } from 'geojson'; import { IGetObservationSubmissionResponse, + IGetSurveyObservationsResponse, ISpatialData, IUploadObservationSubmissionResponse } from 'interfaces/useObservationApi.interface'; @@ -186,7 +187,7 @@ const useObservationApi = (axios: AxiosInstance) => { surveyId: number, surveyObservations: IObservationTableRow[] ): Promise => { - const { data } = await axios.put<{ surveyObservations: IObservationTableRow[] }>( + const { data } = await axios.put( `/api/project/${projectId}/survey/${surveyId}/observation`, { surveyObservations } ); @@ -201,12 +202,12 @@ const useObservationApi = (axios: AxiosInstance) => { * @param {number} surveyId * @return {*} {Promise} */ - const getObservationRecords = async (projectId: number, surveyId: number): Promise => { - const { data } = await axios.get<{ surveyObservations: IObservationTableRow[] }>( + const getObservationRecords = async (projectId: number, surveyId: number): Promise => { + const { data } = await axios.get( `/api/project/${projectId}/survey/${surveyId}/observation` ); - return data.surveyObservations; + return data; }; return { diff --git a/app/src/interfaces/useObservationApi.interface.ts b/app/src/interfaces/useObservationApi.interface.ts index 029d89537e..374c8050db 100644 --- a/app/src/interfaces/useObservationApi.interface.ts +++ b/app/src/interfaces/useObservationApi.interface.ts @@ -1,3 +1,4 @@ +import { IObservationTableRow } from 'contexts/observationsContext'; import { Feature, FeatureCollection } from 'geojson'; export interface IGetSubmissionCSVForViewItem { @@ -85,3 +86,7 @@ export interface ISpatialData { taxa_data: ITaxaData[]; spatial_data: FeatureCollection | EmptyObject; } + +export interface IGetSurveyObservationsResponse { + surveyObservations: IObservationTableRow[] +} From 51c90707690958a36b6cb71ea5e4a7e6958efd54 Mon Sep 17 00:00:00 2001 From: Curtis Upshall Date: Wed, 27 Sep 2023 13:21:03 -0700 Subject: [PATCH 46/62] SIMSBIOHUB-223: Code cleanup --- .../survey/{surveyId}/observation/index.ts | 4 +-- .../repositories/observation-repository.ts | 14 ++++++-- api/src/services/observation-service.ts | 5 +-- app/src/contexts/observationsContext.tsx | 36 ++++++++++++------- 4 files changed, 40 insertions(+), 19 deletions(-) diff --git a/api/src/paths/project/{projectId}/survey/{surveyId}/observation/index.ts b/api/src/paths/project/{projectId}/survey/{surveyId}/observation/index.ts index 9b9f0c03db..aff60217f8 100644 --- a/api/src/paths/project/{projectId}/survey/{surveyId}/observation/index.ts +++ b/api/src/paths/project/{projectId}/survey/{surveyId}/observation/index.ts @@ -316,6 +316,7 @@ export function insertUpdateSurveyObservations(): RequestHandler { const observationService = new ObservationService(connection); + // Sanitize all incoming records const records: (InsertObservation | UpdateObservation)[] = req.body.surveyObservations.map((record: any) => { return { survey_id: surveyId, @@ -329,8 +330,7 @@ export function insertUpdateSurveyObservations(): RequestHandler { }; }); - await observationService.insertUpdateSurveyObservations(surveyId, records); - const surveyObservations = await observationService.getSurveyObservations(surveyId); + const surveyObservations = await observationService.insertUpdateSurveyObservations(surveyId, records); await connection.commit(); diff --git a/api/src/repositories/observation-repository.ts b/api/src/repositories/observation-repository.ts index e614f5743d..b02008f9c5 100644 --- a/api/src/repositories/observation-repository.ts +++ b/api/src/repositories/observation-repository.ts @@ -4,6 +4,9 @@ import { z } from 'zod'; import { getKnex } from '../database/db'; import { BaseRepository } from './base-repository'; +/** + * Interface reflecting survey observations retrieved from the database + */ export const ObservationRecord = z.object({ survey_observation_id: z.number(), survey_id: z.number(), @@ -22,11 +25,17 @@ export const ObservationRecord = z.object({ export type ObservationRecord = z.infer; +/** + * Interface reflecting survey observations that are being inserted into the database + */ export type InsertObservation = Pick< ObservationRecord, 'survey_id' | 'wldtaxonomic_units_id' | 'latitude' | 'longitude' | 'count' | 'observation_date' | 'observation_time' >; +/** + * Interface reflecting survey observations that are being updated in the database + */ export type UpdateObservation = Pick< ObservationRecord, | 'survey_observation_id' @@ -40,7 +49,8 @@ export type UpdateObservation = Pick< export class ObservationRepository extends BaseRepository { /** - * TODO + * Performs an upsert for all observation records belonging to the given survey, then + * returns the updated rows * * @param {number} surveyId * @param {((Observation | ObservationRecord)[])} observations @@ -105,7 +115,7 @@ export class ObservationRepository extends BaseRepository { } /** - * @TODO + * Retrieves all observation records for the given survey * * @param {number} surveyId * @return {*} {Promise} diff --git a/api/src/services/observation-service.ts b/api/src/services/observation-service.ts index 27bb7bab1d..61c3a16593 100644 --- a/api/src/services/observation-service.ts +++ b/api/src/services/observation-service.ts @@ -16,7 +16,8 @@ export class ObservationService extends DBService { } /** - * TODO + * Performs an upsert for all observation records belonging to the given survey, then + * returns the updated rows * * @param {number} surveyId * @param {((Observation | ObservationRecord)[])} observations @@ -31,7 +32,7 @@ export class ObservationService extends DBService { } /** - * TODO + * Retrieves all observation records for the given survey * * @param {number} surveyId * @return {*} {Promise} diff --git a/app/src/contexts/observationsContext.tsx b/app/src/contexts/observationsContext.tsx index a05ee0abf1..109344a560 100644 --- a/app/src/contexts/observationsContext.tsx +++ b/app/src/contexts/observationsContext.tsx @@ -31,11 +31,30 @@ export interface IObservationTableRow extends Partial { * @interface IObservationsContext */ export type IObservationsContext = { + /** + * Appends a new blank record to the observation rows + */ createNewRecord: () => void; + /** + * Commits all observation rows to the database, including those that are currently being + * edited in the Observation Table + */ saveRecords: () => Promise; + /** + * Reverts all changes made to observation records within the Observation Table + */ revertRecords: () => Promise; + /** + * Refreshes the Observation Table with already existing records + */ refreshRecords: () => Promise; + /** + * Data Loader used for retrieving existing records + */ observationsDataLoader: DataLoader<[], IGetSurveyObservationsResponse, unknown> + /** + * API ref used to interface with an MUI DataGrid representing the observation records + */ _muiDataGridApiRef: React.MutableRefObject; }; @@ -103,19 +122,10 @@ export const ObservationsContextProvider = (props: PropsWithChildren ({ - ...row, - id: String(row.survey_observation_id) - }))); - */ - - observationsDataLoader.refresh(); + refreshRecords(); }; const revertRecords = async () => { @@ -123,8 +133,8 @@ export const ObservationsContextProvider = (props: PropsWithChildren _muiDataGridApiRef.current.stopRowEditMode({ id, ignoreModifications: true })); }; - const refreshRecords = async () => { - // + const refreshRecords = async (): Promise => { + return observationsDataLoader.refresh(); }; const observationsContext: IObservationsContext = { From befa0822e8cec2f7f6e990d7c370ae5fe6f5663b Mon Sep 17 00:00:00 2001 From: Curtis Upshall Date: Wed, 27 Sep 2023 14:53:46 -0700 Subject: [PATCH 47/62] SISMBIOHUB-223: Lint fix; ignore-skip --- .../survey/{surveyId}/observation/index.ts | 5 +--- app/src/contexts/observationsContext.tsx | 26 +++++++++---------- .../observations/ObservationsTable.tsx | 14 +++++----- app/src/hooks/api/useObservationApi.ts | 5 +++- .../interfaces/useObservationApi.interface.ts | 2 +- 5 files changed, 27 insertions(+), 25 deletions(-) diff --git a/api/src/paths/project/{projectId}/survey/{surveyId}/observation/index.ts b/api/src/paths/project/{projectId}/survey/{surveyId}/observation/index.ts index aff60217f8..42b2fb117c 100644 --- a/api/src/paths/project/{projectId}/survey/{surveyId}/observation/index.ts +++ b/api/src/paths/project/{projectId}/survey/{surveyId}/observation/index.ts @@ -223,10 +223,7 @@ PUT.apiDoc = { ], properties: { wldtaxonomic_units_id: { - oneOf: [ - { type: 'integer' }, - { type: 'string' } - ] + oneOf: [{ type: 'integer' }, { type: 'string' }] }, count: { type: 'number' diff --git a/app/src/contexts/observationsContext.tsx b/app/src/contexts/observationsContext.tsx index 109344a560..73fae61d78 100644 --- a/app/src/contexts/observationsContext.tsx +++ b/app/src/contexts/observationsContext.tsx @@ -1,11 +1,11 @@ import { GridRowModelUpdate, useGridApiRef } from '@mui/x-data-grid'; import { GridApiCommunity } from '@mui/x-data-grid/internals'; import { useBiohubApi } from 'hooks/useBioHubApi'; +import useDataLoader, { DataLoader } from 'hooks/useDataLoader'; +import { IGetSurveyObservationsResponse } from 'interfaces/useObservationApi.interface'; import { createContext, PropsWithChildren, useContext } from 'react'; import { v4 as uuidv4 } from 'uuid'; import { SurveyContext } from './surveyContext'; -import useDataLoader, { DataLoader } from 'hooks/useDataLoader'; -import { IGetSurveyObservationsResponse } from 'interfaces/useObservationApi.interface'; export interface IObservationRecord { survey_observation_id: number | undefined; @@ -51,7 +51,7 @@ export type IObservationsContext = { /** * Data Loader used for retrieving existing records */ - observationsDataLoader: DataLoader<[], IGetSurveyObservationsResponse, unknown> + observationsDataLoader: DataLoader<[], IGetSurveyObservationsResponse, unknown>; /** * API ref used to interface with an MUI DataGrid representing the observation records */ @@ -99,30 +99,30 @@ export const ObservationsContextProvider = (props: PropsWithChildren { - return Array.from(_muiDataGridApiRef.current.getRowModels?.()?.values()) as IObservationTableRow[] - } + return Array.from(_muiDataGridApiRef.current.getRowModels?.()?.values()) as IObservationTableRow[]; + }; const getActiveRecords = (): IObservationTableRow[] => { return _getRows().map((row) => { - const editRow = _muiDataGridApiRef.current.state.editRows[row.id] + const editRow = _muiDataGridApiRef.current.state.editRows[row.id]; if (!editRow) { return row; } - return Object - .entries(editRow) - .reduce((newRow, entry) => ({ ...row, ...newRow, _isModified: true, [entry[0]]: entry[1].value }), {}); + return Object.entries(editRow).reduce( + (newRow, entry) => ({ ...row, ...newRow, _isModified: true, [entry[0]]: entry[1].value }), + {} + ); }) as IObservationTableRow[]; - } - + }; const saveRecords = async () => { const editingIds = Object.keys(_muiDataGridApiRef.current.state.editRows); editingIds.forEach((id) => _muiDataGridApiRef.current.stopRowEditMode({ id })); const { projectId, surveyId } = surveyContext; - - const rows = getActiveRecords() + + const rows = getActiveRecords(); await biohubApi.observation.insertUpdateObservationRecords(projectId, surveyId, rows); refreshRecords(); diff --git a/app/src/features/surveys/observations/ObservationsTable.tsx b/app/src/features/surveys/observations/ObservationsTable.tsx index b728952d33..2ecccd91fc 100644 --- a/app/src/features/surveys/observations/ObservationsTable.tsx +++ b/app/src/features/surveys/observations/ObservationsTable.tsx @@ -7,8 +7,8 @@ import { useContext, useEffect, useState } from 'react'; export type IObservationsTableProps = Record; -const ObservationsTable = (props: IObservationsTableProps) => { - const [initialRows, setInitialRows] = useState([]); +const ObservationsTable = (props: IObservationsTableProps) => { + const [initialRows, setInitialRows] = useState([]); const observationColumns: GridColDef[] = [ { @@ -115,10 +115,12 @@ const ObservationsTable = (props: IObservationsTableProps) => { useEffect(() => { if (observationsDataLoader.data?.surveyObservations) { - const rows: IObservationTableRow[] = observationsDataLoader.data.surveyObservations.map((row: IObservationTableRow) => ({ - ...row, - id: String(row.survey_observation_id) - })); + const rows: IObservationTableRow[] = observationsDataLoader.data.surveyObservations.map( + (row: IObservationTableRow) => ({ + ...row, + id: String(row.survey_observation_id) + }) + ); setInitialRows(rows); } diff --git a/app/src/hooks/api/useObservationApi.ts b/app/src/hooks/api/useObservationApi.ts index 6fc4ce3a11..0ce822ccd0 100644 --- a/app/src/hooks/api/useObservationApi.ts +++ b/app/src/hooks/api/useObservationApi.ts @@ -202,7 +202,10 @@ const useObservationApi = (axios: AxiosInstance) => { * @param {number} surveyId * @return {*} {Promise} */ - const getObservationRecords = async (projectId: number, surveyId: number): Promise => { + const getObservationRecords = async ( + projectId: number, + surveyId: number + ): Promise => { const { data } = await axios.get( `/api/project/${projectId}/survey/${surveyId}/observation` ); diff --git a/app/src/interfaces/useObservationApi.interface.ts b/app/src/interfaces/useObservationApi.interface.ts index 374c8050db..52a7fbe049 100644 --- a/app/src/interfaces/useObservationApi.interface.ts +++ b/app/src/interfaces/useObservationApi.interface.ts @@ -88,5 +88,5 @@ export interface ISpatialData { } export interface IGetSurveyObservationsResponse { - surveyObservations: IObservationTableRow[] + surveyObservations: IObservationTableRow[]; } From 7f1e4fb8f1b62c8b53a58741f3bc24fb59998089 Mon Sep 17 00:00:00 2001 From: Curtis Upshall Date: Wed, 27 Sep 2023 15:20:37 -0700 Subject: [PATCH 48/62] SIMSBIOHUB-223: Remove prototype stuff --- app/src/AppRouter.tsx | 5 - app/src/pages/prototype/PrototypePage.tsx | 406 ---------------------- 2 files changed, 411 deletions(-) delete mode 100644 app/src/pages/prototype/PrototypePage.tsx diff --git a/app/src/AppRouter.tsx b/app/src/AppRouter.tsx index 25e037713e..6a99d5e127 100644 --- a/app/src/AppRouter.tsx +++ b/app/src/AppRouter.tsx @@ -19,7 +19,6 @@ import LoginPage from 'pages/authentication/LoginPage'; import LogOutPage from 'pages/authentication/LogOutPage'; import { LandingPage } from 'pages/landing/LandingPage'; import { Playground } from 'pages/Playground'; -import { PrototypePage } from 'pages/prototype/PrototypePage'; import React from 'react'; import { Redirect, Route, Switch, useLocation } from 'react-router-dom'; import RouteWithTitle from 'utils/RouteWithTitle'; @@ -116,10 +115,6 @@ const AppRouter: React.FC = () => { - - - - diff --git a/app/src/pages/prototype/PrototypePage.tsx b/app/src/pages/prototype/PrototypePage.tsx deleted file mode 100644 index 4d935884f2..0000000000 --- a/app/src/pages/prototype/PrototypePage.tsx +++ /dev/null @@ -1,406 +0,0 @@ -import { mdiCogOutline, mdiDotsVertical, mdiImport, mdiPlus } from '@mdi/js'; -import Icon from '@mdi/react'; -import Accordion from '@mui/material/Accordion'; -import AccordionDetails from '@mui/material/AccordionDetails'; -import AccordionSummary from '@mui/material/AccordionSummary'; -import Box from '@mui/material/Box'; -import Breadcrumbs from '@mui/material/Breadcrumbs'; -import Button from '@mui/material/Button'; -import { grey } from '@mui/material/colors'; -import IconButton from '@mui/material/IconButton'; -import Link from '@mui/material/Link'; -import List from '@mui/material/List'; -import ListItem from '@mui/material/ListItem'; -import ListItemText from '@mui/material/ListItemText'; -import Paper from '@mui/material/Paper'; -import Toolbar from '@mui/material/Toolbar'; -import Typography from '@mui/material/Typography'; -import { DataGrid } from '@mui/x-data-grid'; - -const columns = [ - { - field: 'speciesName', - headerName: 'Species', - editable: true, - flex: 1, - minWidth: 250, - disableColumnMenu: true - }, - { - field: 'samplingSite', - headerName: 'Sampling Site', - editable: true, - type: 'singleSelect', - valueOptions: ['Site 1', 'Site 2', 'Site 3', 'Site 4'], - flex: 1, - minWidth: 200, - disableColumnMenu: true - }, - { - field: 'samplingMethod', - headerName: 'Sampling Method', - editable: true, - type: 'singleSelect', - valueOptions: ['Method 1', 'Method 2', 'Method 3', 'Method 4'], - flex: 1, - minWidth: 200, - disableColumnMenu: true - }, - { - field: 'samplingPeriod', - headerName: 'Sampling Period', - editable: true, - type: 'singleSelect', - valueOptions: ['Period 1', 'Period 2', 'Period 3', 'Period 4', 'Undefined'], - flex: 1, - minWidth: 200, - disableColumnMenu: true - }, - { - field: 'count', - headerName: 'Count', - editable: true, - type: 'number', - minWidth: 100, - disableColumnMenu: true - }, - { - field: 'date', - headerName: 'Date', - editable: true, - type: 'date', - minWidth: 150, - disableColumnMenu: true - }, - { - field: 'time', - headerName: 'Time', - editable: true, - type: 'time', - width: 150, - disableColumnMenu: true - }, - { - field: 'lat', - headerName: 'Lat', - type: 'number', - editable: true, - width: 150, - disableColumnMenu: true - }, - { - field: 'long', - headerName: 'Long', - type: 'number', - editable: true, - width: 150, - disableColumnMenu: true - }, - { - field: 'actions', - headerName: '', - type: 'actions', - width: 80, - disableColumnMenu: true, - resizable: false, - cellClassName: 'test', - getActions: () => [ - - - - ] - } -]; - -export default function RenderHeaderGrid() { - return ( -
- -
- ); -} - -export const PrototypePage = () => { - return ( - - - - - Survey Name - - - Manage Survey Observations - - - - Manage Survey Observations - - - - - {/* Sampling Site List */} - - - - Sampling Sites - - - - - - No Sampling Sites - - - - - - - Sampling Site 1 - - - - - - - - - - - Method 1 - - - - - - - YYYY-MM-DD to YYYY-MM-DD - - - - - YYYY-MM-DD to YYYY-MM-DD - - - - - - - - - - - Sampling Site 1 - - - - - - - - - - - Method 1 - - - - - - - YYYY-MM-DD to YYYY-MM-DD - - - - - YYYY-MM-DD to YYYY-MM-DD - - - - - - - - - {/* Observations Component */} - - - - Observations - - - - - - - - {/* Table View */} - - - - - - - - - - ); -}; From b67d561dc4565ee205a07a494f896db1445412a0 Mon Sep 17 00:00:00 2001 From: Curtis Upshall Date: Wed, 27 Sep 2023 15:41:31 -0700 Subject: [PATCH 49/62] SISMBIOHUB-223: Removed http-error change; added test boilerplate --- api/src/errors/http-error.ts | 2 +- .../{surveyId}/observation/index.test.ts | 81 +++++++++++++++++++ 2 files changed, 82 insertions(+), 1 deletion(-) create mode 100644 api/src/paths/project/{projectId}/survey/{surveyId}/observation/index.test.ts diff --git a/api/src/errors/http-error.ts b/api/src/errors/http-error.ts index 90eb511370..125f425578 100644 --- a/api/src/errors/http-error.ts +++ b/api/src/errors/http-error.ts @@ -129,5 +129,5 @@ export const ensureHTTPError = (error: HTTPError | ApiError | Error | any): HTTP return new HTTP500('Unexpected Error', [error.name, error.message]); } - return new HTTP500('Unexpected Error', [error]); + return new HTTP500('Unexpected Error'); }; diff --git a/api/src/paths/project/{projectId}/survey/{surveyId}/observation/index.test.ts b/api/src/paths/project/{projectId}/survey/{surveyId}/observation/index.test.ts new file mode 100644 index 0000000000..4cdac15353 --- /dev/null +++ b/api/src/paths/project/{projectId}/survey/{surveyId}/observation/index.test.ts @@ -0,0 +1,81 @@ +import chai, { expect } from 'chai'; +import { describe } from 'mocha'; +import sinon from 'sinon'; +import sinonChai from 'sinon-chai'; +import * as db from '../../../../../../database/db'; +import { HTTPError } from '../../../../../../errors/http-error'; +import { getMockDBConnection, getRequestHandlerMocks } from '../../../../../../__mocks__/db'; +import { ObservationService } from '../../../../../../services/observation-service'; +import { insertUpdateSurveyObservations } from '.'; + +chai.use(sinonChai); + +// TODO remove skip +describe.skip('insertUpdateSurveyObservations', () => { + afterEach(() => { + sinon.restore(); + }); + + describe('openAPI schema', () => { + // TODO + }) + + it('inserts and updates survey observations', async () => { + const dbConnectionObj = getMockDBConnection(); + + sinon.stub(db, 'getDBConnection').returns(dbConnectionObj); + + const insertUpdateSurveyObservationsStub = sinon + .stub(ObservationService.prototype, 'insertUpdateSurveyObservations') + .resolves(); + + const { mockReq, mockRes, mockNext } = getRequestHandlerMocks(); + + mockReq.params = { + projectId: '1', + surveyId: '2' + }; + + mockReq.body = { + // TODO + }; + + try { + const requestHandler = insertUpdateSurveyObservations(); + + await requestHandler(mockReq, mockRes, mockNext); + } catch (actualError) { + expect.fail(); + } + + expect(insertUpdateSurveyObservationsStub).to.have.been.calledOnceWith( + 2, + { + // TODO + } + ); + expect(mockRes.statusValue).to.equal(200); + expect(mockRes.jsonValue).to.eql({ id: 2 }); + }); + + it('catches and re-throws error', async () => { + const dbConnectionObj = getMockDBConnection({ release: sinon.stub() }); + + sinon.stub(db, 'getDBConnection').returns(dbConnectionObj); + + sinon.stub(ObservationService.prototype, 'insertUpdateSurveyObservations').rejects(new Error('a test error')); + + const { mockReq, mockRes, mockNext } = getRequestHandlerMocks(); + + try { + const requestHandler = insertUpdateSurveyObservations(); + + await requestHandler(mockReq, mockRes, mockNext); + expect.fail(); + } catch (actualError) { + expect(dbConnectionObj.release).to.have.been.called; + + expect((actualError as HTTPError).message).to.equal('a test error'); + } + }); +}); From 2270851b0dca5bf6e3a10f84a4c9922032747d36 Mon Sep 17 00:00:00 2001 From: Curtis Upshall Date: Wed, 27 Sep 2023 21:36:36 -0700 Subject: [PATCH 50/62] SISMBIOHUB-223: Added new service and repo methods --- .../survey/{surveyId}/observation/index.ts | 2 +- .../repositories/observation-repository.ts | 26 +++++++++++++++++++ api/src/services/observation-service.ts | 11 ++++++-- 3 files changed, 36 insertions(+), 3 deletions(-) diff --git a/api/src/paths/project/{projectId}/survey/{surveyId}/observation/index.ts b/api/src/paths/project/{projectId}/survey/{surveyId}/observation/index.ts index 42b2fb117c..2040d9609d 100644 --- a/api/src/paths/project/{projectId}/survey/{surveyId}/observation/index.ts +++ b/api/src/paths/project/{projectId}/survey/{surveyId}/observation/index.ts @@ -327,7 +327,7 @@ export function insertUpdateSurveyObservations(): RequestHandler { }; }); - const surveyObservations = await observationService.insertUpdateSurveyObservations(surveyId, records); + const surveyObservations = await observationService.insertUpdateDeleteSurveyObservations(surveyId, records); await connection.commit(); diff --git a/api/src/repositories/observation-repository.ts b/api/src/repositories/observation-repository.ts index b02008f9c5..748c309231 100644 --- a/api/src/repositories/observation-repository.ts +++ b/api/src/repositories/observation-repository.ts @@ -48,6 +48,32 @@ export type UpdateObservation = Pick< >; export class ObservationRepository extends BaseRepository { + /** + * Deletes all survey observation records associated with the given survey, except + * for records whose ID belongs to the given array, then returns the count of + * affected rows. + * + * @param surveyId + * @param retainedObservationIds + */ + async deleteObservationsNotInArray(surveyId: number, retainedObservationIds: number[]): Promise { + const query = SQL` + DELETE FROM + survey_observation + WHERE + survey_id = ${surveyId} + AND + survey_observation_id + NOT IN + `; + + query.append(`(${retainedObservationIds.join(',')})`); + + const response = await this.connection.sql(query); + + return response.rowCount; + } + /** * Performs an upsert for all observation records belonging to the given survey, then * returns the updated rows diff --git a/api/src/services/observation-service.ts b/api/src/services/observation-service.ts index 61c3a16593..71d16dbcc0 100644 --- a/api/src/services/observation-service.ts +++ b/api/src/services/observation-service.ts @@ -16,7 +16,8 @@ export class ObservationService extends DBService { } /** - * Performs an upsert for all observation records belonging to the given survey, then + * Performs an upsert for all observation records belonging to the given survey, while removing + * any records associated for the survey that aren't included in the given records, then * returns the updated rows * * @param {number} surveyId @@ -24,10 +25,16 @@ export class ObservationService extends DBService { * @return {*} {Promise} * @memberof ObservationService */ - async insertUpdateSurveyObservations( + async insertUpdateDeleteSurveyObservations( surveyId: number, observations: (InsertObservation | UpdateObservation)[] ): Promise { + const retainedObservationIds = observations + .filter((observation): observation is UpdateObservation => 'survey_observation_id' in observation && Boolean(observation.survey_observation_id)) + .map((observation) => observation.survey_observation_id); + + await this.observationRepository.deleteObservationsNotInArray(surveyId, retainedObservationIds); + return this.observationRepository.insertUpdateSurveyObservations(surveyId, observations); } From fe52de4baa31bb68b86780189533d5d3a425687b Mon Sep 17 00:00:00 2001 From: Nick Phura Date: Thu, 28 Sep 2023 09:52:40 -0700 Subject: [PATCH 51/62] Add observation repository tests. Update observation delete function to handle empty array of observation ids. --- .../{surveyId}/observation/index.test.ts | 19 +-- .../observation-repository.test.ts | 138 ++++++++++++++++++ .../repositories/observation-repository.ts | 42 +++--- 3 files changed, 171 insertions(+), 28 deletions(-) create mode 100644 api/src/repositories/observation-repository.test.ts diff --git a/api/src/paths/project/{projectId}/survey/{surveyId}/observation/index.test.ts b/api/src/paths/project/{projectId}/survey/{surveyId}/observation/index.test.ts index 4cdac15353..f8436bc69f 100644 --- a/api/src/paths/project/{projectId}/survey/{surveyId}/observation/index.test.ts +++ b/api/src/paths/project/{projectId}/survey/{surveyId}/observation/index.test.ts @@ -2,11 +2,11 @@ import chai, { expect } from 'chai'; import { describe } from 'mocha'; import sinon from 'sinon'; import sinonChai from 'sinon-chai'; +import { insertUpdateSurveyObservations } from '.'; import * as db from '../../../../../../database/db'; import { HTTPError } from '../../../../../../errors/http-error'; -import { getMockDBConnection, getRequestHandlerMocks } from '../../../../../../__mocks__/db'; import { ObservationService } from '../../../../../../services/observation-service'; -import { insertUpdateSurveyObservations } from '.'; +import { getMockDBConnection, getRequestHandlerMocks } from '../../../../../../__mocks__/db'; chai.use(sinonChai); @@ -18,7 +18,7 @@ describe.skip('insertUpdateSurveyObservations', () => { describe('openAPI schema', () => { // TODO - }) + }); it('inserts and updates survey observations', async () => { const dbConnectionObj = getMockDBConnection(); @@ -26,7 +26,7 @@ describe.skip('insertUpdateSurveyObservations', () => { sinon.stub(db, 'getDBConnection').returns(dbConnectionObj); const insertUpdateSurveyObservationsStub = sinon - .stub(ObservationService.prototype, 'insertUpdateSurveyObservations') + .stub(ObservationService.prototype, 'insertUpdateDeleteSurveyObservations') .resolves(); const { mockReq, mockRes, mockNext } = getRequestHandlerMocks(); @@ -48,12 +48,9 @@ describe.skip('insertUpdateSurveyObservations', () => { expect.fail(); } - expect(insertUpdateSurveyObservationsStub).to.have.been.calledOnceWith( - 2, - { - // TODO - } - ); + expect(insertUpdateSurveyObservationsStub).to.have.been.calledOnceWith(2, { + // TODO + }); expect(mockRes.statusValue).to.equal(200); expect(mockRes.jsonValue).to.eql({ id: 2 }); }); @@ -63,7 +60,7 @@ describe.skip('insertUpdateSurveyObservations', () => { sinon.stub(db, 'getDBConnection').returns(dbConnectionObj); - sinon.stub(ObservationService.prototype, 'insertUpdateSurveyObservations').rejects(new Error('a test error')); + sinon.stub(ObservationService.prototype, 'insertUpdateDeleteSurveyObservations').rejects(new Error('a test error')); const { mockReq, mockRes, mockNext } = getRequestHandlerMocks(); diff --git a/api/src/repositories/observation-repository.test.ts b/api/src/repositories/observation-repository.test.ts new file mode 100644 index 0000000000..087a67c90c --- /dev/null +++ b/api/src/repositories/observation-repository.test.ts @@ -0,0 +1,138 @@ +import chai, { expect } from 'chai'; +import { describe } from 'mocha'; +import { QueryResult } from 'pg'; +import sinon from 'sinon'; +import sinonChai from 'sinon-chai'; +import { SQLStatement } from 'sql-template-strings'; +import { getMockDBConnection } from '../__mocks__/db'; +import { InsertObservation, ObservationRepository, UpdateObservation } from './observation-repository'; + +chai.use(sinonChai); + +describe.only('ObservationRepository', () => { + afterEach(() => { + sinon.restore(); + }); + + describe('deleteObservationsNotInArray', () => { + it('should delete all records except for the ids in the provided array', async () => { + const mockQueryResponse = ({ rows: [], rowCount: 3 } as unknown) as QueryResult; + + const mockDBConnection = getMockDBConnection({ + sql: sinon.stub().resolves(mockQueryResponse) + }); + + const surveyId = 1; + const retainedObservationIds = [11, 22]; + + const repo = new ObservationRepository(mockDBConnection); + + const response = await repo.deleteObservationsNotInArray(surveyId, retainedObservationIds); + + expect(response).to.equal(3); + expect(mockDBConnection.sql).to.have.been.calledOnceWith( + sinon.match((sqlStatement: SQLStatement) => { + return ['survey_observation_id', 'NOT IN', '(11,22)'].every((term) => sqlStatement.text.includes(term)); + }) + ); + }); + + it('should delete all records when provided array of ids is empty', async () => { + const mockQueryResponse = ({ rows: [], rowCount: 3 } as unknown) as QueryResult; + + const mockDBConnection = getMockDBConnection({ + sql: sinon.stub().resolves(mockQueryResponse) + }); + + const surveyId = 1; + const retainedObservationIds: number[] = []; + + const repo = new ObservationRepository(mockDBConnection); + + const response = await repo.deleteObservationsNotInArray(surveyId, retainedObservationIds); + + expect(response).to.equal(3); + expect(mockDBConnection.sql).to.have.been.calledOnce; + expect(mockDBConnection.sql).not.to.have.been.calledWith( + sinon.match((sqlStatement: SQLStatement) => { + return ['survey_observation_id', 'NOT IN'].every((term) => sqlStatement.text.includes(term)); + }) + ); + }); + }); + + describe('insertUpdateSurveyObservations', () => { + it('should upsert records and return the affected rows', async () => { + const mockRows = [{}, {}]; + const mockQueryResponse = ({ rows: mockRows, rowCount: 2 } as unknown) as QueryResult; + + const mockDBConnection = getMockDBConnection({ + sql: sinon.stub().resolves(mockQueryResponse) + }); + + const repo = new ObservationRepository(mockDBConnection); + + const surveyId = 1; + const observations: (InsertObservation | UpdateObservation)[] = [ + { + survey_id: 1, + wldtaxonomic_units_id: 2, + latitude: 3, + longitude: 4, + count: 5, + observation_date: '2023-01-01', + observation_time: '12:00:00' + } as InsertObservation, + { + survey_observation_id: 6, + wldtaxonomic_units_id: 7, + latitude: 8, + longitude: 9, + count: 10, + observation_date: '2023-02-02', + observation_time: '13:00:00' + } as UpdateObservation + ]; + + const response = await repo.insertUpdateSurveyObservations(surveyId, observations); + + expect(response).to.be.eql(mockRows); + }); + }); + + describe('getSurveyObservations', () => { + it('get all observations for a survey when some observation records exist', async () => { + const mockRows = [{}, {}]; + const mockQueryResponse = ({ rows: mockRows, rowCount: 2 } as unknown) as QueryResult; + + const mockDBConnection = getMockDBConnection({ + knex: sinon.stub().resolves(mockQueryResponse) + }); + + const repository = new ObservationRepository(mockDBConnection); + + const surveyId = 1; + + const response = await repository.getSurveyObservations(surveyId); + + expect(response).to.be.eql(mockRows); + }); + + it('get all observations for a survey when no observation records exist', async () => { + const mockRows: any[] = []; + const mockQueryResponse = ({ rows: mockRows, rowCount: 2 } as unknown) as QueryResult; + + const mockDBConnection = getMockDBConnection({ + knex: sinon.stub().resolves(mockQueryResponse) + }); + + const repository = new ObservationRepository(mockDBConnection); + + const surveyId = 1; + + const response = await repository.getSurveyObservations(surveyId); + + expect(response).to.be.eql(mockRows); + }); + }); +}); diff --git a/api/src/repositories/observation-repository.ts b/api/src/repositories/observation-repository.ts index 748c309231..e14127c754 100644 --- a/api/src/repositories/observation-repository.ts +++ b/api/src/repositories/observation-repository.ts @@ -52,24 +52,32 @@ export class ObservationRepository extends BaseRepository { * Deletes all survey observation records associated with the given survey, except * for records whose ID belongs to the given array, then returns the count of * affected rows. - * - * @param surveyId - * @param retainedObservationIds + * + * @param {number} surveyId + * @param {number[]} retainedObservationIds Observation records to retain (not be deleted) + * @return {*} {Promise} + * @memberof ObservationRepository */ async deleteObservationsNotInArray(surveyId: number, retainedObservationIds: number[]): Promise { - const query = SQL` + const sqlStatement = SQL` DELETE FROM survey_observation WHERE survey_id = ${surveyId} - AND - survey_observation_id - NOT IN `; - query.append(`(${retainedObservationIds.join(',')})`); + if (retainedObservationIds.length) { + sqlStatement.append(` + AND + survey_observation_id + NOT IN + (${retainedObservationIds.join(',')}) + `); + } + + sqlStatement.append(';'); - const response = await this.connection.sql(query); + const response = await this.connection.sql(sqlStatement); return response.rowCount; } @@ -79,7 +87,7 @@ export class ObservationRepository extends BaseRepository { * returns the updated rows * * @param {number} surveyId - * @param {((Observation | ObservationRecord)[])} observations + * @param {((InsertObservation | UpdateObservation)[])} observations * @return {*} {Promise} * @memberof ObservationRepository */ @@ -87,7 +95,7 @@ export class ObservationRepository extends BaseRepository { surveyId: number, observations: (InsertObservation | UpdateObservation)[] ): Promise { - const query = SQL` + const sqlStatement = SQL` INSERT INTO survey_observation ( @@ -104,7 +112,7 @@ export class ObservationRepository extends BaseRepository { VALUES `; - query.append( + sqlStatement.append( observations .map((observation) => { return `(${[ @@ -121,7 +129,7 @@ export class ObservationRepository extends BaseRepository { .join(', ') ); - query.append(` + sqlStatement.append(` ON CONFLICT (survey_observation_id) DO UPDATE SET @@ -133,9 +141,9 @@ export class ObservationRepository extends BaseRepository { longitude = EXCLUDED.longitude `); - query.append(`RETURNING *;`); + sqlStatement.append(`RETURNING *;`); - const response = await this.connection.sql(query, ObservationRecord); + const response = await this.connection.sql(sqlStatement, ObservationRecord); return response.rows; } @@ -148,9 +156,9 @@ export class ObservationRepository extends BaseRepository { * @memberof ObservationRepository */ async getSurveyObservations(surveyId: number): Promise { - const selectQuery = getKnex().select('*').from('survey_observation').where('survey_id', surveyId); + const sqlStatement = getKnex().select('*').from('survey_observation').where('survey_id', surveyId); - const response = await this.connection.knex(selectQuery, ObservationRecord); + const response = await this.connection.knex(sqlStatement, ObservationRecord); return response.rows; } } From 6bc2044e839ce2ed17f18935ad90e44e8a1d5bca Mon Sep 17 00:00:00 2001 From: Curtis Upshall Date: Thu, 28 Sep 2023 10:22:36 -0700 Subject: [PATCH 52/62] SIMSBIOHUB-223: Removed extra Manage Observations button from SurveyObservations.tsx --- .../survey-observations/SurveyObservations.tsx | 16 ++-------------- 1 file changed, 2 insertions(+), 14 deletions(-) diff --git a/app/src/features/surveys/view/survey-observations/SurveyObservations.tsx b/app/src/features/surveys/view/survey-observations/SurveyObservations.tsx index 429051b9b2..dde8456d6c 100644 --- a/app/src/features/surveys/view/survey-observations/SurveyObservations.tsx +++ b/app/src/features/surveys/view/survey-observations/SurveyObservations.tsx @@ -1,4 +1,4 @@ -import { mdiImport, mdiOpenInNew } from '@mdi/js'; +import { mdiImport } from '@mdi/js'; import Icon from '@mdi/react'; import Box from '@mui/material/Box'; import Button from '@mui/material/Button'; @@ -17,7 +17,6 @@ import { useBiohubApi } from 'hooks/useBioHubApi'; import { useInterval } from 'hooks/useInterval'; import { IUploadObservationSubmissionResponse } from 'interfaces/useObservationApi.interface'; import React, { useContext, useEffect, useState } from 'react'; -import { Link } from 'react-router-dom'; import LoadingObservationsCard from './components/LoadingObservationsCard'; import ObservationFileCard from './components/ObservationFileCard'; import ObservationMessagesCard from './components/ObservationMessagesCard'; @@ -185,18 +184,7 @@ const SurveyObservations: React.FC = () => { }) || occurrenceSubmissionPublishStatus !== PublishStatus.SUBMITTED ) { - return ( - - -