diff --git a/.github/workflows/build-docs.yml b/.github/workflows/build-docs.yml new file mode 100644 index 00000000..b2e1cc47 --- /dev/null +++ b/.github/workflows/build-docs.yml @@ -0,0 +1,101 @@ +name: Build, Test, and Deploy Writerside Documentation + +on: + push: + branches: # Trigger on push to any branch + - "*" + paths: + - "frontend/documentation/**" # Only run on changes in the documentation folder + workflow_dispatch: + +permissions: + id-token: write + pages: write + +env: + INSTANCE: 'documentation/fad' + ARTIFACT: 'webHelpFAD2-all.zip' + DOCKER_VERSION: '243.21565' # Writerside's recommended Docker version + +jobs: + build: + runs-on: ubuntu-latest + steps: + # Step 1: Checkout repository + - name: Checkout repository + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + # Step 2: Build Writerside documentation + - name: Build docs using Writerside Docker builder + uses: JetBrains/writerside-github-action@v4 + with: + instance: ${{ env.INSTANCE }} + artifact: ${{ env.ARTIFACT }} + docker-version: ${{ env.DOCKER_VERSION }} + args: --verbose + + # Debug: List artifacts directory + - name: List artifacts directory + run: ls -la artifacts/ + + # Step 3: Save artifact with build results + - name: Save artifact with build results + uses: actions/upload-artifact@v4 + with: + name: docs + path: | + artifacts/${{ env.ARTIFACT }} + artifacts/report.json + retention-days: 7 + + test: + needs: build + runs-on: ubuntu-latest + steps: + # Step 1: Download artifacts + - name: Download artifacts + uses: actions/download-artifact@v4 + with: + name: docs + path: artifacts + + # Step 2: Test Writerside documentation + - name: Test documentation + uses: JetBrains/writerside-checker-action@v1 + with: + instance: ${{ env.INSTANCE }} + + deploy: + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + needs: [build, test] + runs-on: ubuntu-latest + steps: + # Step 1: Download artifacts + - name: Download artifacts + uses: actions/download-artifact@v4 + with: + name: docs + + # Step 2: Unzip the artifact + - name: Unzip artifact + run: unzip -O UTF-8 -qq '${{ env.ARTIFACT }}' -d dir + + # Step 3: Set up GitHub Pages + - name: Setup Pages + uses: actions/configure-pages@v4 + + # Step 4: Package and upload Pages artifact + - name: Package and upload Pages artifact + uses: actions/upload-pages-artifact@v3 + with: + path: dir + + # Step 5: Deploy to GitHub Pages + - name: Deploy to GitHub Pages + id: deployment + uses: actions/deploy-pages@v4 + diff --git a/.github/workflows/dev-forestgeo-livesite.yml b/.github/workflows/dev-forestgeo-livesite.yml index 3bfbc59b..a6b701b2 100644 --- a/.github/workflows/dev-forestgeo-livesite.yml +++ b/.github/workflows/dev-forestgeo-livesite.yml @@ -19,9 +19,9 @@ jobs: - uses: actions/checkout@v4 - name: Set up Node.js version - uses: actions/setup-node@v3 + uses: actions/setup-node@v4.0.4 with: - node-version: '18.x' + node-version: '20.x' - name: create env file (in frontend/ directory) -- development id: create-env-file-dev @@ -48,22 +48,22 @@ jobs: echo OWNER=${{ secrets.OWNER }} >> frontend/.env echo REPO=${{ secrets.REPO }} >> frontend/.env - - name: Cache node modules - uses: actions/cache@v2 - with: - path: frontend/node_modules - key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }} - restore-keys: | - ${{ runner.os }}-node- +# - name: Cache node modules +# uses: actions/cache@v2 +# with: +# path: frontend/node_modules +# key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }} +# restore-keys: | +# ${{ runner.os }}-node- - - name: Cache Next.js build - uses: actions/cache@v2 - with: - path: frontend/build/cache - key: ${{ runner.os }}-next-${{ hashFiles('**/package-lock.json') }}-${{ hashFiles('**/.next/cache') }} - restore-keys: | - ${{ runner.os }}-next- - ${{ runner.os }}-next-${{ hashFiles('**/package-lock.json') }} +# - name: Cache Next.js build +# uses: actions/cache@v2 +# with: +# path: frontend/build/cache +# key: ${{ runner.os }}-next-${{ hashFiles('**/package-lock.json') }}-${{ hashFiles('**/.next/cache') }} +# restore-keys: | +# ${{ runner.os }}-next- +# ${{ runner.os }}-next-${{ hashFiles('**/package-lock.json') }} - name: move into frontend --> npm install, build, and test run: | diff --git a/.github/workflows/main-forestgeo-livesite.yml b/.github/workflows/main-forestgeo-livesite.yml index 3da01339..86c5e5e2 100644 --- a/.github/workflows/main-forestgeo-livesite.yml +++ b/.github/workflows/main-forestgeo-livesite.yml @@ -13,7 +13,7 @@ jobs: build-app-production: if: github.ref == 'refs/heads/main' runs-on: ubuntu-latest - environment: development + environment: production steps: - uses: actions/checkout@v4 @@ -30,7 +30,6 @@ jobs: echo AZURE_AD_CLIENT_SECRET=${{ secrets.AZURE_AD_CLIENT_SECRET_PRODUCTION }} >> frontend/.env echo AZURE_AD_CLIENT_ID=${{ secrets.AZURE_AD_CLIENT_ID_PRODUCTION }} >> frontend/.env echo AZURE_AD_TENANT_ID=${{ secrets.AZURE_AD_TENANT_ID_PRODUCTION }} >> frontend/.env - echo NEXTAUTH_URL=${{ secrets.NEXTAUTH_URL_DEV }} >> frontend/.env echo NEXTAUTH_SECRET=${{ secrets.NEXTAUTH_SECRET }} >> frontend/.env echo AZURE_SQL_USER=${{ secrets.AZURE_SQL_USER }} >> frontend/.env echo AZURE_SQL_PASSWORD=${{ secrets.AZURE_SQL_PASSWORD }} >> frontend/.env @@ -86,7 +85,7 @@ jobs: deploy-app-production: needs: build-app-production runs-on: ubuntu-latest - environment: development + environment: production steps: - name: Download build artifact @@ -103,4 +102,4 @@ jobs: app-name: 'forestgeo-livesite' slot-name: 'Production' publish-profile: ${{ secrets.AZUREAPPSERVICE_PUBLISHPROFILE_PRODUCTION }} - package: frontend/build/standalone + package: frontend/build/standalone \ No newline at end of file diff --git a/frontend/CHANGELOG.md b/frontend/CHANGELOG.md index 63f4382e..c04d7c05 100644 --- a/frontend/CHANGELOG.md +++ b/frontend/CHANGELOG.md @@ -118,11 +118,11 @@ 1. FixedData cases' queries updated to correctly work with updated schemas 2. New tables/cases added: - 1. `personnelrole` - 2. `sitespecificvalidations` - 3. `roles` - 4. `measurementssummary` - 5. `viewfulltable` + 1. `personnelrole` + 2. `sitespecificvalidations` + 3. `roles` + 4. `measurementssummary` + 5. `viewfulltable` ###### POST @@ -142,18 +142,18 @@ 1. Postvalidation summary statistics calculation endpoint 2. Statistics queries: - 1. `number of records by quadrat` - 2. `all stem records by quadrat (count only)` - 3. `live stem records by quadrat (count only)` - 4. `tree records by quadrat (count only)` - 5. `number of dead or missing stems by census` - 6. `trees outside of plot limits` - 7. `stems with largest DBH/HOM measurements by species` - 8. `all trees that were recorded in last census that are NOT in current census` - 9. `number of new stems per quadrat per census` - 10. `quadrats with most and least new stems per census` - 11. `number of dead stems per quadrat per census` - 12. `number of dead stems per species per census` + 1. `number of records by quadrat` + 2. `all stem records by quadrat (count only)` + 3. `live stem records by quadrat (count only)` + 4. `tree records by quadrat (count only)` + 5. `number of dead or missing stems by census` + 6. `trees outside of plot limits` + 7. `stems with largest DBH/HOM measurements by species` + 8. `all trees that were recorded in last census that are NOT in current census` + 9. `number of new stems per quadrat per census` + 10. `quadrats with most and least new stems per census` + 11. `number of dead stems per quadrat per census` + 12. `number of dead stems per species per census` #### frontend/app/api/refreshviews/[view]/[schema]/route.ts @@ -220,7 +220,7 @@ 3. customized cell and edit cell rendering added 4. some exceptions exist -- for instances where specific additional handling is needed, column states are directly defined in the datagrid components themselves. - 1. `alltaxonomiesview` -- specieslimits column customized addition + 1. `alltaxonomiesview` -- specieslimits column customized addition #### GitHub Feedback Modal @@ -250,18 +250,18 @@ 1. The DataGridCommons generic datagrid instance has been replaced by the IsolatedDataGridCommons instance, which isolates as much information as possible to the generic instance rather than the existing DataGridCommons, which requires parameter drilling of all MUI X DataGrid parameters. Current datagrids using this new implementation are: - - `alltaxonomiesview` - - `attributes` - - `personnel` - - `quadratpersonnel` - - `quadrats` - - `roles` - - `stemtaxonomiesview` + - `alltaxonomiesview` + - `attributes` + - `personnel` + - `quadratpersonnel` + - `quadrats` + - `roles` + - `stemtaxonomiesview` 2. found that attempting to use typescript runtime utilities to create "default" initial states for each RDS type was causing cascading failures. Due to the way that runtime utility functions work, no data was actually reaching the datagrids importing those initial states - 1. replaced with manual definition of initial states -- planning on centralizing this to another place, similar to - the `datagridcolumns.tsx` file + 1. replaced with manual definition of initial states -- planning on centralizing this to another place, similar to + the `datagridcolumns.tsx` file 3. `measurementssummaryview` datagrid instance added as a replacement to the previously defined summary page #### Re-Entry Data Modal @@ -307,20 +307,19 @@ 7. materialized view reload has been adjusted to be optional. user should be able to continue the process even if one or more of the views fails. ---- +--- ### SQL Updates 1. Schema has been been updated -- new tables added: - 1. `roles` - outlines user roles - 2. `specieslimits` - allows setting min/max bounds on measurements - 3. `specimens` - recording specimen data (added on request by ForestGEO) - 4. `unifiedchangelog` - partitioned table that tracks all changes to all tables in schema. All tables have triggers - that automatically update the `unifiedchangelog` on every change - 5. `sitespecificvalidations` - for specific validations applicable only to the host site + 1. `roles` - outlines user roles + 2. `specieslimits` - allows setting min/max bounds on measurements + 3. `specimens` - recording specimen data (added on request by ForestGEO) + 4. `unifiedchangelog` - partitioned table that tracks all changes to all tables in schema. All tables have triggers + that automatically update the `unifiedchangelog` on every change + 5. `sitespecificvalidations` - for specific validations applicable only to the host site 2. validation stored procedures have been deprecated and removed, replaced with `validationprocedures` and `sitespecificvalidations` tables 3. migration script set has been completed and tested 4. trigger definitions have been recorded 5. view implementations have been updated - diff --git a/frontend/README.md b/frontend/README.md index bafe24a1..59ca98fa 100644 --- a/frontend/README.md +++ b/frontend/README.md @@ -1,6 +1,5 @@ -# The ForestGEO Data Entry App +# ForestGEO Census Management Application -liquibase generate-changelog --exclude-objects="\b\w*view\w*\b" A cloud-native web application built to accelerate the pace of research for the Smithsonian Institution's Forest Global Earth Observatory (ForestGEO). ForestGEO is a global forest research network, unparalleled in size and scope, comprised of ecologists and research sites dedicated to @@ -8,92 +7,56 @@ advancing long-term study of the world's forests. The ForestGEO app aims to empo an efficient means of recording, validating, and publishing forest health data. Learn more about ForestGEO [at their website](https://www.forestgeo.si.edu/). -This application was built using Next.js 13 (app directory) and NextUI (v2). - -### Technical documentation: - -Please see the -documentation [here](https://github.com/ForestGeoHack/ForestGEO/wiki/ForestGEO-App-Specification) - -## Project Structure - -- `prev_app/`: previous iteration of the ForestGEO app, which uses the - Next.js v12 Pages router system. You can step into this directory to run the previous iteration of - the application -- `app/`: the primary routing structure and setup for the primary application -- `components/`: requisite react components that are used within the application and icon - information -- `config/`: fonts information and general site information -- endpoint names, plot names, plot - interface, etc. -- `styles/`: tailwindcss formatting files and dropzone/validation table custom formatting files -- `types/`: additional set up for SVG formatting - -### Running the project - -1. Before running the project, you must create an `.env.local` file in the overhead directory with - the following values: - - `AZURE_AD_CLIENT_ID` - - `AZURE_AD_CLIENT_SECRET` - - `AZURE_AD_TENANT_ID` - - `NEXTAUTH_SECRET` - - `NEXTAUTH_URL` - - all `AZURE_` values must be created/populated from Azure's App Registration portal -2. Once `.env.local` is made, run `npm install` from the overhead directory to install dependencies -3. Run `npm run build` to compile/optimize the application for running -4. Run `npm run dev` to create a dev instance of the application locally on your machine -5. Navigate to `http://localhost:3000` to access the application - ---- - -### Understanding Next.JS Dynamic Routing - -Next.js's dynamic routing setup allows for built-in endpoint data processing. By using this, passing -data from a component or root layout to a page/endpoint is simplified (rather than using useCallback -or a React function). As a brief reminder, remember that when using Next.js 13, writing something -like `app/example/filehandling.tsx` will generate a route pointing to `... /example` instead -of `.../example/page`, and nesting successive folders will create a route with those -folders: `app/example1/example2/example3/filehandling.tsx` has the -route `... /example1/example2/example3/`. - -For a better explanation of how this works, please observe the browse -endpoint: `app/(endpoints)/browse/[plotKey]/[plotNum]/filehandling.tsx`
-In order from left to right, please note the following points of interest: - -- `(endpoints)`: wrapping a folder in parentheses allows for better organization w/o using the - wrapped folder name in the path. For example, accessing the Browse page locally does not require - adding `/endpoints/` to the URL -- `[plotKey]`: this is the first required variable when accessing this endpoint -- you will have to - add some string `plotKey` to the end of the URL: `.../browse/[your plot key]` in order to - successfully view the page. - - wrapping a folder in `[]` will designate that folder as a **required** dynamic parameter - - wrapping in `[...folderName]` designates `folderName` as a catch-all route. All following - values after `folderName` (i.e., `.../a/b` will return `folderName = [a, b]` ) - - wrapping in `[[...folderName]]` designates `folderName` as an _optional_ catch-all route. As - expected, all values for/after `folderName` will be returned as part of the dynamic route, - but `undefined` will also be returned if no value is entered at all (instead of a 404 error) -- `[plotNum]`: second required variable when accessing this endpoint - your resulting endpoint will - look like (example) `http://localhost:3000/browse/plotKey/plotNum`. - ---- - -### Release Notes (v0.1.0): - -- endpoints have been added and routed to require a plot key/number combination for access - - initial state has been converted to new `Plot {key: 'none', num: 0}` instead of `''` -- MUI JoyUI has been partially implemented as a replacement for MaterialUI. However, due to time - limitations, MaterialUI has still been incorporated into converted sections from ForestGeoHack - - The current plan is to solely implement either NextUI or ChakraUI instead of either of these - options, and future updates will include this information. -- `SelectPlotProps` has been removed and replaced with NextJS dynamic routing (each endpoint will - dynamically retrieve plot information). Endpoints have been updated to reflect dynamic param-based - retrieval - - The navigation bar has been updated to use useEffect to push live endpoint updates when the - plot is changed (if you are at an endpoint and the plot is changed, the page will be reloaded - to reflect that) -- New components/moved-over information: - - `Fileuploadcomponents` --> css code has been udpated to be dark theme-friendly - - `FileList` --> moved over - - `Loginlogout` --> created component, login/logout process has been relegated to avatar icon - dropdown menu - - `Plotselection` --> partially created from SelectPlot, changed to utilize dynamic - routing/selection instead of requiring a new dropdown in each page +## Setting up for Local Development + +This project uses NextJS v14(+), and server interactions and setup are handled through their interface. Please note +that for local development, you will **not** be able to use the NextJS-provided `next start` command due to the way that +the application is packaged for Azure deployment. Instead, please use the `next dev` command to start the local +development server to use the application. + +> Note: the development server compiles and loads the application in real time as you interact with the website. +> Accordingly, **load times for API endpoints and other components will be much longer than the actual site's.** Please +> do not use these load times as an indicator of load times within the deployed application instance! + +### Production vs Development Branches + +The `main` branch of this repository is the production branch, and the `forestgeo-app-development` is the deployed +development branch. When adding new features or making changes to the application, please branch off of the +`forestgeo-app-development` branch instead of `main`. The production branch should not be used as a baseline and should +only be modified via approved PRs. + +### Azure-side Setup Requirements + +The application maintains a live connection to an Azure Storage and a Azure MySQL server instance. Before you can use +the application, please ensure that you work with a ForestGEO administrator to: + +1. add your email address to the managing database, +2. provide you with a role and, +3. assign the testing schemas to your account + +> It is critical that live sites actively being used by researchers are not mistakenly modified or damaged! + +### Setting up the Environment + +> **Note:** The following instructions assume that you have `NodeJS` and `npm` installed on your local machine. + +After cloning the repository, please run `npm install` to install required dependencies. + +The application requires a set of environmental variables stored in a `.env.local` file in order to run locally. Please +contact a repository administrator to request access to the key-vault storage, named `forestgeo-app-key-vault`. Once you +can access it, please retrieve all noted secrets in the repository and place them in your `.env.local` file. The name of +the secret corresponds to the name of the environmental variable. Please use the following example as a template: + +Let's assume that the keyvault storage has a secret named `EXAMPLE-SECRET`, with a corresponding value of `1234`. +In order to use this secret in your local environment, add it to your `.env.local` file like this: + +`EXAMPLE_SECRET=1234` + +Please note that the name of the secret in the keyvault uses **hyphens**, while the name of the environmental variable +uses **underscores**. Please ensure you **replace all hyphens with underscores** when adding the secret to your +`.env.local` file. + +Once you have successfully created your `.env.local` file, please run `npm run dev` to start the local development +server. + +> **Ensure that you have port 3000 open and available on your local machine before starting the server!** diff --git a/frontend/app/(hub)/dashboard/page.tsx b/frontend/app/(hub)/dashboard/page.tsx index 5343d85f..50fc8d1d 100644 --- a/frontend/app/(hub)/dashboard/page.tsx +++ b/frontend/app/(hub)/dashboard/page.tsx @@ -50,7 +50,6 @@ export default function DashboardPage() { try { setIsLoading(true); - // Check if the required data is available, otherwise return a padded array if (!currentSite || !currentPlot || !currentCensus) { setChangelogHistory(Array(5).fill({})); return; @@ -158,14 +157,6 @@ export default function DashboardPage() { - - - - Plot-Species List - See existing taxonomy information for stems in your plot and census here.{' '} - Requires a census. - - - diff --git a/frontend/app/(hub)/fixeddatainput/stemtaxonomies/error.tsx b/frontend/app/(hub)/fixeddatainput/stemtaxonomies/error.tsx deleted file mode 100644 index eaaea954..00000000 --- a/frontend/app/(hub)/fixeddatainput/stemtaxonomies/error.tsx +++ /dev/null @@ -1,16 +0,0 @@ -'use client'; - -import React from 'react'; -import { Box, Button, Typography } from '@mui/joy'; - -const ErrorPage = ({ error, reset }: { error: Error; reset: () => void }) => { - return ( - - Something went wrong - Plot-Species List - {error.message} - - - ); -}; - -export default ErrorPage; diff --git a/frontend/app/(hub)/fixeddatainput/stemtaxonomies/page.tsx b/frontend/app/(hub)/fixeddatainput/stemtaxonomies/page.tsx deleted file mode 100644 index 74cd3733..00000000 --- a/frontend/app/(hub)/fixeddatainput/stemtaxonomies/page.tsx +++ /dev/null @@ -1,7 +0,0 @@ -'use client'; - -import IsolatedStemTaxonomiesViewDataGrid from '@/components/datagrids/applications/isolated/isolatedstemtaxonomiesviewdatagrid'; - -export default function StemTaxonomiesPage() { - return ; -} diff --git a/frontend/app/(hub)/layout.tsx b/frontend/app/(hub)/layout.tsx index 5e917153..b299935e 100644 --- a/frontend/app/(hub)/layout.tsx +++ b/frontend/app/(hub)/layout.tsx @@ -7,7 +7,6 @@ import dynamic from 'next/dynamic'; import { Box, IconButton, Stack, Typography } from '@mui/joy'; import Divider from '@mui/joy/Divider'; import { useLoading } from '@/app/contexts/loadingprovider'; -import { getAllSchemas } from '@/components/processors/processorhelperfunctions'; import { useOrgCensusContext, usePlotContext, useSiteContext } from '@/app/contexts/userselectionprovider'; import { useOrgCensusListDispatch, usePlotListDispatch, useQuadratListDispatch, useSiteListDispatch } from '@/app/contexts/listselectionprovider'; import { getEndpointHeaderName, siteConfig } from '@/config/macros/siteconfigs'; @@ -96,7 +95,7 @@ export default function HubLayout({ children }: { children: React.ReactNode }) { } } } catch (e: any) { - const allsites = await getAllSchemas(); + const allsites = await (await fetch(`/api/fetchall/sites?schema=${currentSite?.schemaName ?? ''}`)).json(); if (siteListDispatch) await siteListDispatch({ siteList: allsites }); } finally { setLoading(false); diff --git a/frontend/app/(hub)/measurementshub/postvalidation/page.tsx b/frontend/app/(hub)/measurementshub/postvalidation/page.tsx index 86ecfb66..2e28040a 100644 --- a/frontend/app/(hub)/measurementshub/postvalidation/page.tsx +++ b/frontend/app/(hub)/measurementshub/postvalidation/page.tsx @@ -1,92 +1,246 @@ 'use client'; import { useOrgCensusContext, usePlotContext, useSiteContext } from '@/app/contexts/userselectionprovider'; -import { useEffect, useState } from 'react'; -import { Box, LinearProgress } from '@mui/joy'; - -interface PostValidations { - queryID: number; - queryName: string; - queryDescription: string; -} - -interface PostValidationResults { - count: number; - data: any; -} +import React, { useEffect, useState } from 'react'; +import { Box, Button, Checkbox, Table, Typography, useTheme } from '@mui/joy'; +import { PostValidationQueriesRDS } from '@/config/sqlrdsdefinitions/validations'; +import PostValidationRow from '@/components/client/postvalidationrow'; +import { Paper, TableBody, TableCell, TableContainer, TableHead, TableRow } from '@mui/material'; +import { Done } from '@mui/icons-material'; +import { useLoading } from '@/app/contexts/loadingprovider'; export default function PostValidationPage() { const currentSite = useSiteContext(); const currentPlot = usePlotContext(); const currentCensus = useOrgCensusContext(); - const [postValidations, setPostValidations] = useState([]); - const [validationResults, setValidationResults] = useState>({}); - const [loadingQueries, setLoadingQueries] = useState(false); + const [postValidations, setPostValidations] = useState([]); + const [expandedQuery, setExpandedQuery] = useState(null); + const [expandedResults, setExpandedResults] = useState(null); + const [selectedResults, setSelectedResults] = useState([]); + const replacements = { + schema: currentSite?.schemaName, + currentPlotID: currentPlot?.plotID, + currentCensusID: currentCensus?.dateRanges[0].censusID + }; + const { setLoading } = useLoading(); - // Fetch post-validation queries on first render - useEffect(() => { - async function loadQueries() { - try { - setLoadingQueries(true); - const response = await fetch(`/api/postvalidation?schema=${currentSite?.schemaName}`, { method: 'GET' }); - const data = await response.json(); - setPostValidations(data); - } catch (error) { - console.error('Error loading queries:', error); - } finally { - setLoadingQueries(false); - } + const enabledPostValidations = postValidations.filter(query => query.isEnabled); + const disabledPostValidations = postValidations.filter(query => !query.isEnabled); + + const theme = useTheme(); + const isDarkMode = theme.palette.mode === 'dark'; + + async function fetchValidationResults(postValidation: PostValidationQueriesRDS) { + if (!postValidation.queryID) return; + try { + await fetch( + `/api/postvalidationbyquery/${currentSite?.schemaName}/${currentPlot?.plotID}/${currentCensus?.dateRanges[0].censusID}/${postValidation.queryID}`, + { method: 'GET' } + ); + } catch (error: any) { + console.error(`Error fetching validation results for query ${postValidation.queryID}:`, error); + throw new Error(error); } + } - if (currentSite?.schemaName) { - loadQueries(); + async function loadPostValidations() { + try { + const response = await fetch(`/api/fetchall/postvalidationqueries?schema=${currentSite?.schemaName}`, { method: 'GET' }); + const data = await response.json(); + setPostValidations(data); + } catch (error) { + console.error('Error loading queries:', error); } - }, [currentSite?.schemaName]); + } - // Fetch validation results for each query - useEffect(() => { - async function fetchValidationResults(postValidation: PostValidations) { - try { - const response = await fetch( - `/api/postvalidationbyquery/${currentSite?.schemaName}/${currentPlot?.plotID}/${currentCensus?.dateRanges[0].censusID}/${postValidation.queryID}`, - { method: 'GET' } - ); - const data = await response.json(); - setValidationResults(prev => ({ - ...prev, - [postValidation.queryID]: data - })); - } catch (error) { - console.error(`Error fetching validation results for query ${postValidation.queryID}:`, error); - setValidationResults(prev => ({ - ...prev, - [postValidation.queryID]: null // Mark as failed if there was an error - })); - } + function saveResultsToFile() { + if (selectedResults.length === 0) { + alert('Please select at least one result to save.'); + return; + } + const blob = new Blob([JSON.stringify(selectedResults, null, 2)], { type: 'application/json' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = 'results.json'; + a.click(); + URL.revokeObjectURL(url); + } + + function printResults() { + if (selectedResults.length === 0) { + alert('Please select at least one result to print.'); + return; } + const printContent = selectedResults.map(result => JSON.stringify(result, null, 2)).join('\n\n'); + const printWindow = window.open('', '', 'width=600,height=400'); + printWindow?.document.write(`
${printContent}
`); + printWindow?.document.close(); + printWindow?.print(); + } - if (postValidations.length > 0 && currentPlot?.plotID && currentCensus?.dateRanges) { - postValidations.forEach(postValidation => { - fetchValidationResults(postValidation).then(r => console.log(r)); - }); + useEffect(() => { + setLoading(true); + loadPostValidations() + .catch(console.error) + .then(() => setLoading(false)); + }, []); + + const handleExpandClick = (queryID: number) => { + setExpandedQuery(expandedQuery === queryID ? null : queryID); + }; + + const handleExpandResultsClick = (queryID: number) => { + setExpandedResults(expandedResults === queryID ? null : queryID); + }; + + const handleSelectResult = (postVal: PostValidationQueriesRDS) => { + setSelectedResults(prev => (prev.includes(postVal) ? prev.filter(id => id !== postVal) : [...prev, postVal])); + }; + + const handleSelectAllChange = (event: React.ChangeEvent) => { + if (event.target.checked) { + // Select all: add all validations to selectedResults + setSelectedResults([...enabledPostValidations, ...disabledPostValidations]); + } else { + // Deselect all: clear selectedResults + setSelectedResults([]); } - }, [postValidations, currentPlot?.plotID, currentCensus?.dateRanges, currentSite?.schemaName]); + }; + + // Check if all items are selected + const isAllSelected = selectedResults.length === postValidations.length && postValidations.length > 0; return ( - - {loadingQueries ? ( - - ) : postValidations.length > 0 ? ( - - {postValidations.map(postValidation => ( - -
{postValidation.queryName}
- {validationResults[postValidation.queryID] ? : } -
- ))} + + + These statistics can be used to analyze entered data. Please select and run, download, or print statistics as needed. + + + + + + + + {postValidations.length > 0 ? ( + + + + + + + + } + label={isAllSelected ? 'Deselect All' : 'Select All'} + checked={isAllSelected} + slotProps={{ + root: ({ checked, focusVisible }) => ({ + sx: !checked + ? { + '& svg': { opacity: focusVisible ? 1 : 0 }, + '&:hover svg': { + opacity: 1 + } + } + : undefined + }) + }} + onChange={e => handleSelectAllChange(e)} + /> + + Query Name + + Query Definition + + Description + Last Run At + Last Run Result + + + + + {enabledPostValidations.map(postValidation => ( + + ))} + + {disabledPostValidations.map(postValidation => ( + + ))} + +
+
) : ( -
No validations available.
+ No validations available. )}
); diff --git a/frontend/app/(hub)/measurementshub/summary/page.tsx b/frontend/app/(hub)/measurementshub/summary/page.tsx index da8b5f8b..7b894282 100644 --- a/frontend/app/(hub)/measurementshub/summary/page.tsx +++ b/frontend/app/(hub)/measurementshub/summary/page.tsx @@ -1,4 +1,4 @@ -import MeasurementsSummaryViewDataGrid from '@/components/datagrids/applications/measurementssummaryviewdatagrid'; +import MeasurementsSummaryViewDataGrid from '@/components/datagrids/applications/msvdatagrid'; export default function SummaryPage() { return ; diff --git a/frontend/app/(hub)/measurementshub/validations/page.tsx b/frontend/app/(hub)/measurementshub/validations/page.tsx index 99afe371..8fd2c617 100644 --- a/frontend/app/(hub)/measurementshub/validations/page.tsx +++ b/frontend/app/(hub)/measurementshub/validations/page.tsx @@ -62,7 +62,6 @@ export default function ValidationsPage() { try { const response = await fetch('/api/validations/crud', { method: 'GET' }); const data = await response.json(); - console.log('data: ', data); setGlobalValidations(data); } catch (err) { console.error('Error fetching validations:', err); diff --git a/frontend/app/api/auth/[[...nextauth]]/route.ts b/frontend/app/api/auth/[[...nextauth]]/route.ts index 64a1f752..519747a3 100644 --- a/frontend/app/api/auth/[[...nextauth]]/route.ts +++ b/frontend/app/api/auth/[[...nextauth]]/route.ts @@ -1,9 +1,9 @@ import NextAuth, { AzureADProfile } from 'next-auth'; import AzureADProvider from 'next-auth/providers/azure-ad'; -import { getAllowedSchemas, getAllSchemas } from '@/components/processors/processorhelperfunctions'; import { UserAuthRoles } from '@/config/macros'; -import { SitesRDS } from '@/config/sqlrdsdefinitions/zones'; -import { getConn, runQuery } from '@/components/processors/processormacros'; +import { SitesRDS, SitesResult } from '@/config/sqlrdsdefinitions/zones'; +import ConnectionManager from '@/config/connectionmanager'; +import MapperFactory from '@/config/datamapper'; const handler = NextAuth({ secret: process.env.NEXTAUTH_SECRET!, @@ -21,44 +21,41 @@ const handler = NextAuth({ }, callbacks: { async signIn({ user, profile, email: signInEmail }) { - console.log('callback -- signin'); const azureProfile = profile as AzureADProfile; const userEmail = user.email || signInEmail || azureProfile.preferred_username; - console.log('user email: ', userEmail); if (typeof userEmail !== 'string') { console.error('User email is not a string:', userEmail); return false; // Email is not a valid string, abort sign-in } if (userEmail) { - console.log('getting connection'); - let conn, emailVerified, userStatus; + const connectionManager = new ConnectionManager(); + let emailVerified, userStatus, userID; try { - conn = await getConn(); - console.log('obtained'); - const query = `SELECT UserStatus FROM catalog.users WHERE Email = '${userEmail}' LIMIT 1`; - const results = await runQuery(conn, query); - console.log('results: ', results); + const query = `SELECT UserID, UserStatus FROM catalog.users WHERE Email = '${userEmail}' LIMIT 1`; + const results = await connectionManager.executeQuery(query); // emailVerified is true if there is at least one result emailVerified = results.length > 0; - console.log('emailVerified: ', emailVerified); if (!emailVerified) { console.error('User email not found.'); return false; } userStatus = results[0].UserStatus; - console.log('userStatus: ', userStatus); + userID = results[0].UserID; } catch (e: any) { console.error('Error fetching user status:', e); throw new Error('Failed to fetch user status.'); - } finally { - if (conn) conn.release(); } user.userStatus = userStatus as UserAuthRoles; user.email = userEmail; // console.log('getting all sites: '); - const allSites = await getAllSchemas(); - const allowedSites = await getAllowedSchemas(userEmail); + const allSites = MapperFactory.getMapper('sites').mapData(await connectionManager.executeQuery(`SELECT * FROM catalog.sites`)); + const allowedSites = MapperFactory.getMapper('sites').mapData( + await connectionManager.executeQuery( + `SELECT s.* FROM catalog.sites AS s JOIN catalog.usersiterelations AS usr ON s.SiteID = usr.SiteID WHERE usr.UserID = ?`, + [userID] + ) + ); if (!allowedSites || !allSites) { console.error('User does not have any allowed sites.'); return false; @@ -66,7 +63,6 @@ const handler = NextAuth({ user.sites = allowedSites; user.allsites = allSites; - // console.log('all sites: ', user.allsites); } return true; }, diff --git a/frontend/app/api/bulkcrud/[dataType]/[[...slugs]]/route.ts b/frontend/app/api/bulkcrud/[dataType]/[[...slugs]]/route.ts new file mode 100644 index 00000000..f8936e95 --- /dev/null +++ b/frontend/app/api/bulkcrud/[dataType]/[[...slugs]]/route.ts @@ -0,0 +1,55 @@ +// bulk data CRUD flow API endpoint -- intended to allow multiline interactions and bulk updates via datagrid +import { NextRequest, NextResponse } from 'next/server'; +import { FileRowSet } from '@/config/macros/formdetails'; +import { insertOrUpdate } from '@/components/processors/processorhelperfunctions'; +import { HTTPResponses, InsertUpdateProcessingProps } from '@/config/macros'; +import ConnectionManager from '@/config/connectionmanager'; + +export async function POST(request: NextRequest, { params }: { params: { dataType: string; slugs?: string[] } }) { + const { dataType, slugs } = params; + if (!dataType || !slugs) { + return new NextResponse('No dataType or SLUGS provided', { status: HTTPResponses.INVALID_REQUEST }); + } + const [schema, plotIDParam, censusIDParam] = slugs; + const plotID = parseInt(plotIDParam); + const censusID = parseInt(censusIDParam); + console.log('params: schema: ', schema, ', plotID: ', plotID, ', censusID: ', censusID); + const rows: FileRowSet = await request.json(); + if (!rows) { + return new NextResponse('No rows provided', { status: 400 }); + } + console.log('rows produced: ', rows); + const connectionManager = new ConnectionManager(); + try { + for (const rowID in rows) { + await connectionManager.beginTransaction(); + const rowData = rows[rowID]; + console.log('rowData obtained: ', rowData); + const props: InsertUpdateProcessingProps = { + schema, + connectionManager: connectionManager, + formType: dataType, + rowData, + plotID, + censusID, + quadratID: undefined, + fullName: undefined + }; + console.log('assembled props: ', props); + await insertOrUpdate(props); + await connectionManager.commitTransaction(); + } + return new NextResponse(JSON.stringify({ message: 'Insert to SQL successful' }), { status: HTTPResponses.OK }); + } catch (e: any) { + await connectionManager.rollbackTransaction(); + return new NextResponse( + JSON.stringify({ + responseMessage: `Failure in connecting to SQL with ${e.message}`, + error: e.message + }), + { status: HTTPResponses.INTERNAL_SERVER_ERROR } + ); + } finally { + await connectionManager.closeConnection(); + } +} diff --git a/frontend/app/api/catalog/[firstName]/[lastName]/route.ts b/frontend/app/api/catalog/[firstName]/[lastName]/route.ts new file mode 100644 index 00000000..773769d1 --- /dev/null +++ b/frontend/app/api/catalog/[firstName]/[lastName]/route.ts @@ -0,0 +1,24 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { HTTPResponses } from '@/config/macros'; +import ConnectionManager from '@/config/connectionmanager'; + +export async function GET(_request: NextRequest, { params }: { params: { firstName: string; lastName: string } }) { + const { firstName, lastName } = params; + if (!firstName || !lastName) throw new Error('no first or last name provided!'); + + const connectionManager = new ConnectionManager(); + + try { + const query = `SELECT UserID FROM catalog.users WHERE FirstName = ? AND LastName = ?;`; + const results = await connectionManager.executeQuery(query, [firstName, lastName]); + if (results.length === 0) { + throw new Error('User not found'); + } + return new NextResponse(JSON.stringify(results[0].UserID), { status: HTTPResponses.OK }); + } catch (e: any) { + console.error('Error in GET request:', e.message); + return new NextResponse(JSON.stringify({ error: e.message }), { status: HTTPResponses.INTERNAL_SERVER_ERROR }); + } finally { + await connectionManager.closeConnection(); + } +} diff --git a/frontend/app/api/changelog/overview/[changelogType]/[[...options]]/route.ts b/frontend/app/api/changelog/overview/[changelogType]/[[...options]]/route.ts index 12b814eb..7eaba2fd 100644 --- a/frontend/app/api/changelog/overview/[changelogType]/[[...options]]/route.ts +++ b/frontend/app/api/changelog/overview/[changelogType]/[[...options]]/route.ts @@ -1,8 +1,7 @@ import { NextRequest, NextResponse } from 'next/server'; -import { PoolConnection } from 'mysql2/promise'; -import { getConn, runQuery } from '@/components/processors/processormacros'; import { HTTPResponses } from '@/config/macros'; import MapperFactory from '@/config/datamapper'; +import ConnectionManager from '@/config/connectionmanager'; export async function GET(request: NextRequest, { params }: { params: { changelogType: string; options?: string[] } }) { const schema = request.nextUrl.searchParams.get('schema'); @@ -13,9 +12,8 @@ export async function GET(request: NextRequest, { params }: { params: { changelo const [plotIDParam, pcnParam] = params.options; const plotID = parseInt(plotIDParam); const pcn = parseInt(pcnParam); - let conn: PoolConnection | null = null; + const connectionManager = new ConnectionManager(); try { - conn = await getConn(); let query = ``; switch (params.changelogType) { case 'unifiedchangelog': @@ -34,13 +32,13 @@ export async function GET(request: NextRequest, { params }: { params: { changelo break; } - const results = await runQuery(conn, query, [plotID, plotID, pcn]); + const results = await connectionManager.executeQuery(query, [plotID, plotID, pcn]); return new NextResponse(results.length > 0 ? JSON.stringify(MapperFactory.getMapper(params.changelogType).mapData(results)) : null, { status: HTTPResponses.OK }); } catch (e: any) { throw new Error('SQL query failed: ' + e.message); } finally { - if (conn) conn.release(); + await connectionManager.closeConnection(); } } diff --git a/frontend/app/api/cmprevalidation/[dataType]/[[...slugs]]/route.ts b/frontend/app/api/cmprevalidation/[dataType]/[[...slugs]]/route.ts index 23a2f19f..94d49a3a 100644 --- a/frontend/app/api/cmprevalidation/[dataType]/[[...slugs]]/route.ts +++ b/frontend/app/api/cmprevalidation/[dataType]/[[...slugs]]/route.ts @@ -1,7 +1,6 @@ -import { getConn, runQuery } from '@/components/processors/processormacros'; -import { PoolConnection } from 'mysql2/promise'; import { NextRequest, NextResponse } from 'next/server'; import { HTTPResponses } from '@/config/macros'; +import ConnectionManager from '@/config/connectionmanager'; // datatype: table name // expecting 1) schema 2) plotID 3) plotCensusNumber @@ -20,16 +19,13 @@ export async function GET(_request: NextRequest, { params }: { params: { dataTyp ) throw new Error('incorrect slugs provided'); - let connection: PoolConnection | null = null; + const connection = new ConnectionManager(); try { - connection = await getConn(); - switch (params.dataType) { case 'attributes': case 'species': const baseQuery = `SELECT 1 FROM ${schema}.${params.dataType} LIMIT 1`; // Check if the table has any row - const baseResults = await runQuery(connection, baseQuery); - if (connection) connection.release(); + const baseResults = await connection.executeQuery(baseQuery); if (baseResults.length === 0) return new NextResponse(null, { status: HTTPResponses.PRECONDITION_VALIDATION_FAILURE @@ -37,8 +33,7 @@ export async function GET(_request: NextRequest, { params }: { params: { dataTyp break; case 'personnel': const pQuery = `SELECT 1 FROM ${schema}.personnel WHERE CensusID IN (SELECT CensusID from ${schema}.census WHERE PlotID = ${plotID} AND PlotCensusNumber = ${plotCensusNumber})`; // Check if the table has any row - const pResults = await runQuery(connection, pQuery); - if (connection) connection.release(); + const pResults = await connection.executeQuery(pQuery); if (pResults.length === 0) return new NextResponse(null, { status: HTTPResponses.PRECONDITION_VALIDATION_FAILURE @@ -49,26 +44,23 @@ export async function GET(_request: NextRequest, { params }: { params: { dataTyp JOIN ${schema}.censusquadrat cq ON cq.QuadratID = q.QuadratID JOIN ${schema}.census c ON cq.CensusID = c.CensusID WHERE q.PlotID = ${plotID} AND c.PlotCensusNumber = ${plotCensusNumber} LIMIT 1`; - const results = await runQuery(connection, query); - if (connection) connection.release(); + const results = await connection.executeQuery(query); if (results.length === 0) return new NextResponse(null, { status: HTTPResponses.PRECONDITION_VALIDATION_FAILURE }); break; - // case 'subquadrats': - // const subquadratsQuery = `SELECT 1 - // FROM ${schema}.${params.dataType} s - // JOIN ${schema}.quadrats q ON s.QuadratID = q.QuadratID - // WHERE q.PlotID = ${plotID} - // AND q.CensusID IN (SELECT CensusID from ${schema}.census WHERE PlotID = ${plotID} AND PlotCensusNumber = ${plotCensusNumber}) LIMIT 1`; - // const subquadratsResults = await runQuery(connection, subquadratsQuery); - // if (connection) connection.release(); - // if (subquadratsResults.length === 0) - // return new NextResponse(null, { - // status: HTTPResponses.PRECONDITION_VALIDATION_FAILURE - // }); - // break; + case 'postvalidation': + const pvQuery = `SELECT 1 FROM ${schema}.coremeasurements cm + JOIN ${schema}.census c ON C.CensusID = cm.CensusID + JOIN ${schema}.plots p ON p.PlotID = c.PlotID + WHERE p.PlotID = ${plotID} AND c.PlotCensusNumber = ${plotCensusNumber} LIMIT 1`; + const pvResults = await connection.executeQuery(pvQuery); + if (pvResults.length === 0) + return new NextResponse(null, { + status: HTTPResponses.PRECONDITION_VALIDATION_FAILURE + }); + break; case 'quadratpersonnel': // Validation for quadrats table const quadratsQuery = `SELECT 1 @@ -78,8 +70,7 @@ export async function GET(_request: NextRequest, { params }: { params: { dataTyp JOIN ${schema}.personnel p ON p.CensusID = c.CensusID WHERE q.PlotID = ${plotID} AND c.PlotCensusNumber = ${plotCensusNumber} LIMIT 1`; - const quadratsResults = await runQuery(connection, quadratsQuery); - if (connection) connection.release(); + const quadratsResults = await connection.executeQuery(quadratsQuery); if (quadratsResults.length === 0) return new NextResponse(null, { status: HTTPResponses.PRECONDITION_VALIDATION_FAILURE @@ -87,8 +78,7 @@ export async function GET(_request: NextRequest, { params }: { params: { dataTyp // Validation for personnel table const personnelQuery = `SELECT 1 FROM ${schema}.personnel LIMIT 1`; - const personnelResults = await runQuery(connection, personnelQuery); - if (connection) connection.release(); + const personnelResults = await connection.executeQuery(personnelQuery); if (personnelResults.length === 0) return new NextResponse(null, { status: HTTPResponses.PRECONDITION_VALIDATION_FAILURE @@ -101,7 +91,6 @@ export async function GET(_request: NextRequest, { params }: { params: { dataTyp }); } // If all conditions are satisfied - connection.release(); return new NextResponse(null, { status: HTTPResponses.OK }); } catch (e: any) { console.error(e); @@ -109,6 +98,6 @@ export async function GET(_request: NextRequest, { params }: { params: { dataTyp status: HTTPResponses.PRECONDITION_VALIDATION_FAILURE }); } finally { - if (connection) connection.release(); + await connection.closeConnection(); } } diff --git a/frontend/app/api/details/cmid/route.ts b/frontend/app/api/details/cmid/route.ts index c9f169f2..6454d4cb 100644 --- a/frontend/app/api/details/cmid/route.ts +++ b/frontend/app/api/details/cmid/route.ts @@ -1,15 +1,13 @@ import { NextRequest, NextResponse } from 'next/server'; -import { getConn, runQuery } from '@/components/processors/processormacros'; -import { PoolConnection } from 'mysql2/promise'; import { HTTPResponses } from '@/config/macros'; +import ConnectionManager from '@/config/connectionmanager'; export async function GET(request: NextRequest) { const cmID = parseInt(request.nextUrl.searchParams.get('cmid')!); const schema = request.nextUrl.searchParams.get('schema'); if (!schema) throw new Error('no schema variable provided!'); - let conn: PoolConnection | null = null; + const connectionManager = new ConnectionManager(); try { - conn = await getConn(); const query = ` SELECT cm.CoreMeasurementID, @@ -35,7 +33,7 @@ export async function GET(request: NextRequest) { ${schema}.census c ON cm.CensusID = c.CensusID WHERE cm.CoreMeasurementID = ?;`; - const results = await runQuery(conn, query, [cmID]); + const results = await connectionManager.executeQuery(query, [cmID]); return new NextResponse( JSON.stringify( results.map((row: any) => ({ @@ -51,6 +49,6 @@ export async function GET(request: NextRequest) { } catch (error: any) { throw new Error('SQL query failed: ' + error.message); } finally { - if (conn) conn.release(); + await connectionManager.closeConnection(); } } diff --git a/frontend/app/api/fetchall/[[...slugs]]/route.ts b/frontend/app/api/fetchall/[[...slugs]]/route.ts index 42daf7c6..61dea390 100644 --- a/frontend/app/api/fetchall/[[...slugs]]/route.ts +++ b/frontend/app/api/fetchall/[[...slugs]]/route.ts @@ -1,8 +1,7 @@ -import { PoolConnection } from 'mysql2/promise'; import { NextRequest, NextResponse } from 'next/server'; -import { getConn, runQuery } from '@/components/processors/processormacros'; import MapperFactory from '@/config/datamapper'; import { HTTPResponses } from '@/config/macros'; +import ConnectionManager from '@/config/connectionmanager'; const buildQuery = (schema: string, fetchType: string, plotID?: string, plotCensusNumber?: string, quadratID?: string): string => { if (fetchType === 'plots') { @@ -14,7 +13,7 @@ const buildQuery = (schema: string, fetchType: string, plotID?: string, plotCens ${schema}.quadrats q ON p.PlotID = q.PlotID GROUP BY p.PlotID ${plotID && plotID !== 'undefined' && !isNaN(parseInt(plotID)) ? `HAVING p.PlotID = ${plotID}` : ''}`; - } else if (fetchType === 'roles') { + } else if (fetchType === 'roles' || fetchType === 'attributes') { return `SELECT * FROM ${schema}.${fetchType}`; } else if (fetchType === 'quadrats') { @@ -59,16 +58,15 @@ export async function GET(request: NextRequest, { params }: { params: { slugs?: console.log('fetchall --> slugs provided: fetchType: ', dataType, 'plotID: ', plotID, 'plotcensusnumber: ', plotCensusNumber, 'quadratID: ', quadratID); const query = buildQuery(schema, dataType, plotID, plotCensusNumber, quadratID); - let conn: PoolConnection | null = null; + const connectionManager = new ConnectionManager(); try { - conn = await getConn(); - const results = await runQuery(conn, query); + const results = await connectionManager.executeQuery(query); return new NextResponse(JSON.stringify(MapperFactory.getMapper(dataType).mapData(results)), { status: HTTPResponses.OK }); } catch (error) { console.error('Error:', error); throw new Error('Call failed'); } finally { - if (conn) conn.release(); + await connectionManager.closeConnection(); } } diff --git a/frontend/app/api/fixeddata/[dataType]/[[...slugs]]/route.ts b/frontend/app/api/fixeddata/[dataType]/[[...slugs]]/route.ts index f8cd1329..1aadb314 100644 --- a/frontend/app/api/fixeddata/[dataType]/[[...slugs]]/route.ts +++ b/frontend/app/api/fixeddata/[dataType]/[[...slugs]]/route.ts @@ -1,15 +1,10 @@ -import { getConn, runQuery } from '@/components/processors/processormacros'; import MapperFactory from '@/config/datamapper'; import { handleError } from '@/utils/errorhandler'; -import { format, PoolConnection } from 'mysql2/promise'; +import { format } from 'mysql2/promise'; import { NextRequest, NextResponse } from 'next/server'; -import { - AllTaxonomiesViewQueryConfig, - handleDeleteForSlices, - handleUpsertForSlices, - StemTaxonomiesViewQueryConfig -} from '@/components/processors/processorhelperfunctions'; -import { HTTPResponses } from '@/config/macros'; // slugs SHOULD CONTAIN AT MINIMUM: schema, page, pageSize, plotID, plotCensusNumber, (optional) quadratID, (optional) speciesID +import { AllTaxonomiesViewQueryConfig, handleDeleteForSlices, handleUpsertForSlices } from '@/components/processors/processorhelperfunctions'; +import { HTTPResponses } from '@/config/macros'; +import ConnectionManager from '@/config/connectionmanager'; // slugs SHOULD CONTAIN AT MINIMUM: schema, page, pageSize, plotID, plotCensusNumber, (optional) quadratID, (optional) speciesID // slugs SHOULD CONTAIN AT MINIMUM: schema, page, pageSize, plotID, plotCensusNumber, (optional) quadratID, (optional) speciesID export async function GET( @@ -19,24 +14,20 @@ export async function GET( }: { params: { dataType: string; slugs?: string[] }; } -): Promise> { +): Promise> { if (!params.slugs || params.slugs.length < 5) throw new Error('slugs not received.'); - const [schema, pageParam, pageSizeParam, plotIDParam, plotCensusNumberParam, quadratIDParam, speciesIDParam] = params.slugs; + const [schema, pageParam, pageSizeParam, plotIDParam, plotCensusNumberParam, speciesIDParam] = params.slugs; if (!schema || schema === 'undefined' || !pageParam || pageParam === 'undefined' || !pageSizeParam || pageSizeParam === 'undefined') throw new Error('core slugs schema/page/pageSize not correctly received'); const page = parseInt(pageParam); const pageSize = parseInt(pageSizeParam); const plotID = plotIDParam ? parseInt(plotIDParam) : undefined; const plotCensusNumber = plotCensusNumberParam ? parseInt(plotCensusNumberParam) : undefined; - const quadratID = quadratIDParam ? parseInt(quadratIDParam) : undefined; const speciesID = speciesIDParam ? parseInt(speciesIDParam) : undefined; - let conn: PoolConnection | null = null; - let updatedMeasurementsExist = false; - let censusIDs; - let pastCensusIDs: string | any[]; + + const connectionManager = new ConnectionManager(); try { - conn = await getConn(); let paginatedQuery = ``; const queryParams: any[] = []; @@ -55,7 +46,6 @@ export async function GET( case 'species': case 'stems': case 'alltaxonomiesview': - case 'stemtaxonomiesview': case 'quadratpersonnel': case 'sitespecificvalidations': case 'roles': @@ -64,9 +54,9 @@ export async function GET( break; case 'personnel': paginatedQuery = ` - SELECT SQL_CALC_FOUND_ROWS q.* - FROM ${schema}.${params.dataType} q - JOIN ${schema}.census c ON q.CensusID = c.CensusID + SELECT SQL_CALC_FOUND_ROWS p.* + FROM ${schema}.${params.dataType} p + JOIN ${schema}.census c ON p.CensusID = c.CensusID WHERE c.PlotID = ? AND c.PlotCensusNumber = ? LIMIT ?, ?;`; queryParams.push(plotID, plotCensusNumber, page * pageSize, pageSize); @@ -99,35 +89,6 @@ export async function GET( WHERE c.PlotID = ? AND c.PlotCensusNumber = ? LIMIT ?, ?;`; queryParams.push(plotID, plotCensusNumber, page * pageSize, pageSize); break; - case 'measurementssummary': - case 'measurementssummaryview': - case 'viewfulltable': - case 'viewfulltableview': - paginatedQuery = ` - SELECT SQL_CALC_FOUND_ROWS q.* - FROM ${schema}.${params.dataType} q - JOIN ${schema}.census c ON q.PlotID = c.PlotID AND q.CensusID = c.CensusID - WHERE q.PlotID = ? - AND c.PlotID = ? - AND c.PlotCensusNumber = ? - ORDER BY q.MeasurementDate ASC LIMIT ?, ?;`; - queryParams.push(plotID, plotID, plotCensusNumber, page * pageSize, pageSize); - break; - // case 'subquadrats': - // if (!quadratID || quadratID === 0) { - // throw new Error('QuadratID must be provided as part of slug fetch query, referenced fixeddata slug route'); - // } - // paginatedQuery = ` - // SELECT SQL_CALC_FOUND_ROWS s.* - // FROM ${schema}.subquadrats s - // JOIN ${schema}.quadrats q ON s.QuadratID = q.QuadratID - // JOIN ${schema}.census c ON q.CensusID = c.CensusID - // WHERE q.QuadratID = ? - // AND q.PlotID = ? - // AND c.PlotID = ? - // AND c.PlotCensusNumber = ? LIMIT ?, ?;`; - // queryParams.push(quadratID, plotID, plotID, plotCensusNumber, page * pageSize, pageSize); - // break; case 'census': paginatedQuery = ` SELECT SQL_CALC_FOUND_ROWS * @@ -135,41 +96,6 @@ export async function GET( WHERE PlotID = ? LIMIT ?, ?`; queryParams.push(plotID, page * pageSize, pageSize); break; - case 'coremeasurements': - // Retrieve multiple past CensusID for the given PlotCensusNumber - const censusQuery = ` - SELECT CensusID - FROM ${schema}.census - WHERE PlotID = ? - AND PlotCensusNumber = ? - ORDER BY StartDate DESC LIMIT 30 - `; - const censusResults = await runQuery(conn, format(censusQuery, [plotID, plotCensusNumber])); - if (censusResults.length < 2) { - paginatedQuery = ` - SELECT SQL_CALC_FOUND_ROWS pdt.* - FROM ${schema}.${params.dataType} pdt - JOIN ${schema}.census c ON pdt.CensusID = c.CensusID - WHERE c.PlotID = ? - AND c.PlotCensusNumber = ? - ORDER BY pdt.MeasurementDate LIMIT ?, ?`; - queryParams.push(plotID, plotCensusNumber, page * pageSize, pageSize); - break; - } else { - updatedMeasurementsExist = true; - censusIDs = censusResults.map((c: any) => c.CensusID); - pastCensusIDs = censusIDs.slice(1); - // Query to fetch paginated measurements from measurementssummaryview - paginatedQuery = ` - SELECT SQL_CALC_FOUND_ROWS pdt.* - FROM ${schema}.${params.dataType} pdt - JOIN ${schema}.census c ON sp.CensusID = c.CensusID - WHERE c.PlotID = ? - AND c.CensusID IN (${censusIDs.map(() => '?').join(', ')}) - ORDER BY pdt.MeasurementDate ASC LIMIT ?, ?`; - queryParams.push(plotID, ...censusIDs, page * pageSize, pageSize); - break; - } default: throw new Error(`Unknown dataType: ${params.dataType}`); } @@ -178,44 +104,25 @@ export async function GET( if (paginatedQuery.match(/\?/g)?.length !== queryParams.length) { throw new Error('Mismatch between query placeholders and parameters'); } - - const paginatedResults = await runQuery(conn, format(paginatedQuery, queryParams)); + const paginatedResults = await connectionManager.executeQuery(format(paginatedQuery, queryParams)); const totalRowsQuery = 'SELECT FOUND_ROWS() as totalRows'; - const totalRowsResult = await runQuery(conn, totalRowsQuery); + const totalRowsResult = await connectionManager.executeQuery(totalRowsQuery); const totalRows = totalRowsResult[0].totalRows; - if (updatedMeasurementsExist) { - // Separate deprecated and non-deprecated rows - const deprecated = paginatedResults.filter((row: any) => pastCensusIDs.includes(row.CensusID)); - - // Ensure deprecated measurements are duplicates - const uniqueKeys = ['PlotID', 'QuadratID', 'TreeID', 'StemID']; // Define unique keys that should match - const outputKeys = paginatedResults.map((row: any) => uniqueKeys.map(key => row[key]).join('|')); - const filteredDeprecated = deprecated.filter((row: any) => outputKeys.includes(uniqueKeys.map(key => row[key]).join('|'))); - return new NextResponse( - JSON.stringify({ - output: MapperFactory.getMapper(params.dataType).mapData(paginatedResults), - deprecated: MapperFactory.getMapper(params.dataType).mapData(filteredDeprecated), - totalCount: totalRows - }), - { status: HTTPResponses.OK } - ); - } else { - return new NextResponse( - JSON.stringify({ - output: MapperFactory.getMapper(params.dataType).mapData(paginatedResults), - deprecated: undefined, - totalCount: totalRows - }), - { status: HTTPResponses.OK } - ); - } + return new NextResponse( + JSON.stringify({ + output: MapperFactory.getMapper(params.dataType).mapData(paginatedResults), + deprecated: undefined, + totalCount: totalRows, + finishedQuery: format(paginatedQuery, queryParams) + }), + { status: HTTPResponses.OK } + ); } catch (error: any) { - if (conn) await conn.rollback(); throw new Error(error); } finally { - if (conn) conn.release(); + await connectionManager.closeConnection(); } } @@ -228,13 +135,12 @@ export async function POST(request: NextRequest, { params }: { params: { dataTyp const plotID = plotIDParam ? parseInt(plotIDParam) : undefined; const censusID = censusIDParam ? parseInt(censusIDParam) : undefined; - let conn: PoolConnection | null = null; + const connectionManager = new ConnectionManager(); const { newRow } = await request.json(); let insertIDs: { [key: string]: number } = {}; try { - conn = await getConn(); - await conn.beginTransaction(); + await connectionManager.beginTransaction(); if (Object.keys(newRow).includes('isNew')) delete newRow.isNew; @@ -248,20 +154,17 @@ export async function POST(request: NextRequest, { params }: { params: { dataTyp case 'alltaxonomiesview': queryConfig = AllTaxonomiesViewQueryConfig; break; - case 'stemtaxonomiesview': - queryConfig = StemTaxonomiesViewQueryConfig; - break; default: throw new Error('Incorrect view call'); } // Use handleUpsertForSlices and retrieve the insert IDs - insertIDs = await handleUpsertForSlices(conn, schema, newRowData, queryConfig); + insertIDs = await handleUpsertForSlices(connectionManager, schema, newRowData, queryConfig); } // Handle the case for 'attributes' else if (params.dataType === 'attributes') { const insertQuery = format('INSERT INTO ?? SET ?', [`${schema}.${params.dataType}`, newRowData]); - const results = await runQuery(conn, insertQuery); + const results = await connectionManager.executeQuery(insertQuery); insertIDs = { attributes: results.insertId }; // Standardize output with table name as key } // Handle all other cases @@ -269,24 +172,22 @@ export async function POST(request: NextRequest, { params }: { params: { dataTyp delete newRowData[demappedGridID]; if (params.dataType === 'plots') delete newRowData.NumQuadrats; const insertQuery = format('INSERT INTO ?? SET ?', [`${schema}.${params.dataType}`, newRowData]); - const results = await runQuery(conn, insertQuery); + const results = await connectionManager.executeQuery(insertQuery); insertIDs = { [params.dataType]: results.insertId }; // Standardize output with table name as key // special handling needed for quadrats --> need to correlate incoming quadrats with current census if (params.dataType === 'quadrats' && censusID) { const cqQuery = format('INSERT INTO ?? SET ?', [`${schema}.censusquadrats`, { CensusID: censusID, QuadratID: insertIDs.quadrats }]); - const results = await runQuery(conn, cqQuery); + const results = await connectionManager.executeQuery(cqQuery); if (results.length === 0) throw new Error('Error inserting to censusquadrats'); } } - // Commit the transaction and return the standardized response - await conn.commit(); return NextResponse.json({ message: 'Insert successful', createdIDs: insertIDs }, { status: HTTPResponses.OK }); } catch (error: any) { - return handleError(error, conn, newRow); + return handleError(error, connectionManager, newRow); } finally { - if (conn) conn.release(); + await connectionManager.closeConnection(); } } @@ -296,31 +197,27 @@ export async function PATCH(request: NextRequest, { params }: { params: { dataTy const [schema, gridID] = params.slugs; if (!schema || !gridID) throw new Error('no schema or gridID provided'); - let conn: PoolConnection | null = null; + const connectionManager = new ConnectionManager(); const demappedGridID = gridID.charAt(0).toUpperCase() + gridID.substring(1); const { newRow, oldRow } = await request.json(); let updateIDs: { [key: string]: number } = {}; try { - conn = await getConn(); - await conn.beginTransaction(); + await connectionManager.beginTransaction(); // Handle views with handleUpsertForSlices (applies to both insert and update logic) - if (['alltaxonomiesview', 'stemtaxonomiesview'].includes(params.dataType)) { + if (params.dataType === 'alltaxonomiesview') { let queryConfig; switch (params.dataType) { case 'alltaxonomiesview': queryConfig = AllTaxonomiesViewQueryConfig; break; - case 'stemtaxonomiesview': - queryConfig = StemTaxonomiesViewQueryConfig; - break; default: throw new Error('Incorrect view call'); } // Use handleUpsertForSlices for update operations as well (updates where needed) - updateIDs = await handleUpsertForSlices(conn, schema, newRow, queryConfig); + updateIDs = await handleUpsertForSlices(connectionManager, schema, newRow, queryConfig); } // Handle non-view table updates @@ -337,21 +234,17 @@ export async function PATCH(request: NextRequest, { params }: { params: { dataTy ); // Execute the UPDATE query - await runQuery(conn, updateQuery); + await connectionManager.executeQuery(updateQuery); // For non-view tables, standardize the response format updateIDs = { [params.dataType]: gridIDKey }; } - // Commit the transaction - await conn.commit(); - - // Return a standardized response with updated IDs return NextResponse.json({ message: 'Update successful', updatedIDs: updateIDs }, { status: HTTPResponses.OK }); } catch (error: any) { - return handleError(error, conn, newRow); + return handleError(error, connectionManager, newRow); } finally { - if (conn) conn.release(); + await connectionManager.closeConnection(); } } @@ -361,16 +254,14 @@ export async function DELETE(request: NextRequest, { params }: { params: { dataT if (!params.slugs) throw new Error('slugs not provided'); const [schema, gridID] = params.slugs; if (!schema || !gridID) throw new Error('no schema or gridID provided'); - let conn: PoolConnection | null = null; + const connectionManager = new ConnectionManager(); const demappedGridID = gridID.charAt(0).toUpperCase() + gridID.substring(1); const { newRow } = await request.json(); - console.log('newrow: ', newRow); try { - conn = await getConn(); - await conn.beginTransaction(); + await connectionManager.beginTransaction(); // Handle deletion for views - if (['alltaxonomiesview', 'stemtaxonomiesview', 'measurementssummaryview'].includes(params.dataType)) { + if (['alltaxonomiesview', 'measurementssummaryview'].includes(params.dataType)) { const deleteRowData = MapperFactory.getMapper(params.dataType).demapData([newRow])[0]; // Prepare query configuration based on view @@ -379,17 +270,12 @@ export async function DELETE(request: NextRequest, { params }: { params: { dataT case 'alltaxonomiesview': queryConfig = AllTaxonomiesViewQueryConfig; break; - case 'stemtaxonomiesview': - queryConfig = StemTaxonomiesViewQueryConfig; - break; default: throw new Error('Incorrect view call'); } // Use handleDeleteForSlices for handling deletion, taking foreign key constraints into account - await handleDeleteForSlices(conn, schema, deleteRowData, queryConfig); - - await conn.commit(); + await handleDeleteForSlices(connectionManager, schema, deleteRowData, queryConfig); return NextResponse.json({ message: 'Delete successful' }, { status: HTTPResponses.OK }); } @@ -399,14 +285,14 @@ export async function DELETE(request: NextRequest, { params }: { params: { dataT // for quadrats, censusquadrat needs to be cleared before quadrat can be deleted if (params.dataType === 'quadrats') { const qDeleteQuery = format(`DELETE FROM ?? WHERE ?? = ?`, [`${schema}.censusquadrat`, demappedGridID, gridIDKey]); - await runQuery(conn, qDeleteQuery); + await connectionManager.executeQuery(qDeleteQuery); } const deleteQuery = format(`DELETE FROM ?? WHERE ?? = ?`, [`${schema}.${params.dataType}`, demappedGridID, gridIDKey]); - await runQuery(conn, deleteQuery); - await conn.commit(); + await connectionManager.executeQuery(deleteQuery); return NextResponse.json({ message: 'Delete successful' }, { status: HTTPResponses.OK }); } catch (error: any) { if (error.code === 'ER_ROW_IS_REFERENCED_2') { + await connectionManager.rollbackTransaction(); const referencingTableMatch = error.message.match(/CONSTRAINT `(.*?)` FOREIGN KEY \(`(.*?)`\) REFERENCES `(.*?)`/); const referencingTable = referencingTableMatch ? referencingTableMatch[3] : 'unknown'; return NextResponse.json( @@ -416,9 +302,8 @@ export async function DELETE(request: NextRequest, { params }: { params: { dataT }, { status: HTTPResponses.FOREIGN_KEY_CONFLICT } ); - } - return handleError(error, conn, newRow); + } else return handleError(error, connectionManager, newRow); } finally { - if (conn) conn.release(); + await connectionManager.closeConnection(); } } diff --git a/frontend/app/api/fixeddatafilter/[dataType]/[[...slugs]]/route.ts b/frontend/app/api/fixeddatafilter/[dataType]/[[...slugs]]/route.ts new file mode 100644 index 00000000..0403225b --- /dev/null +++ b/frontend/app/api/fixeddatafilter/[dataType]/[[...slugs]]/route.ts @@ -0,0 +1,478 @@ +import { NextRequest, NextResponse } from 'next/server'; +import ConnectionManager from '@/config/connectionmanager'; +import { escape } from 'mysql2'; +import { format } from 'mysql2/promise'; +import MapperFactory from '@/config/datamapper'; +import { HTTPResponses } from '@/config/macros'; +import { GridFilterItem, GridFilterModel } from '@mui/x-data-grid'; +import { handleError } from '@/utils/errorhandler'; +import { AllTaxonomiesViewQueryConfig, handleDeleteForSlices, handleUpsertForSlices } from '@/components/processors/processorhelperfunctions'; + +type VisibleFilter = 'valid' | 'errors' | 'pending'; + +interface ExtendedGridFilterModel extends GridFilterModel { + visible: VisibleFilter[]; +} + +export async function POST( + request: NextRequest, + { + params + }: { + params: { dataType: string; slugs?: string[] }; + } +) { + // trying to ensure that system correctly retains edit/add functionality -- not necessarily needed currently but better safe than sorry + const body = await request.json(); + if (body.newRow) { + console.log('newRow path'); + // required dynamic parameters: dataType (fixed),[ schema, gridID value] -> slugs + if (!params.slugs) throw new Error('slugs not provided'); + const [schema, gridID, plotIDParam, censusIDParam] = params.slugs; + if (!schema || !gridID) throw new Error('no schema or gridID provided'); + + const plotID = plotIDParam ? parseInt(plotIDParam) : undefined; + const censusID = censusIDParam ? parseInt(censusIDParam) : undefined; + + const connectionManager = new ConnectionManager(); + const { newRow } = await request.json(); + let insertIDs: { [key: string]: number } = {}; + + try { + await connectionManager.beginTransaction(); + + if (Object.keys(newRow).includes('isNew')) delete newRow.isNew; + + const newRowData = MapperFactory.getMapper(params.dataType).demapData([newRow])[0]; + const demappedGridID = gridID.charAt(0).toUpperCase() + gridID.substring(1); + + // Handle SQL views with handleUpsertForSlices + if (params.dataType.includes('view')) { + let queryConfig; + switch (params.dataType) { + case 'alltaxonomiesview': + queryConfig = AllTaxonomiesViewQueryConfig; + break; + default: + throw new Error('Incorrect view call'); + } + + // Use handleUpsertForSlices and retrieve the insert IDs + insertIDs = await handleUpsertForSlices(connectionManager, schema, newRowData, queryConfig); + } + // Handle the case for 'attributes' + else if (params.dataType === 'attributes') { + const insertQuery = format('INSERT INTO ?? SET ?', [`${schema}.${params.dataType}`, newRowData]); + const results = await connectionManager.executeQuery(insertQuery); + insertIDs = { attributes: results.insertId }; // Standardize output with table name as key + } + // Handle all other cases + else { + delete newRowData[demappedGridID]; + if (params.dataType === 'plots') delete newRowData.NumQuadrats; + const insertQuery = format('INSERT INTO ?? SET ?', [`${schema}.${params.dataType}`, newRowData]); + const results = await connectionManager.executeQuery(insertQuery); + insertIDs = { [params.dataType]: results.insertId }; // Standardize output with table name as key + + // special handling needed for quadrats --> need to correlate incoming quadrats with current census + if (params.dataType === 'quadrats' && censusID) { + const cqQuery = format('INSERT INTO ?? SET ?', [`${schema}.censusquadrats`, { CensusID: censusID, QuadratID: insertIDs.quadrats }]); + const results = await connectionManager.executeQuery(cqQuery); + if (results.length === 0) throw new Error('Error inserting to censusquadrats'); + } + } + + return NextResponse.json({ message: 'Insert successful', createdIDs: insertIDs }, { status: HTTPResponses.OK }); + } catch (error: any) { + return handleError(error, connectionManager, newRow); + } finally { + await connectionManager.closeConnection(); + } + } else { + console.log('non new row path'); + const filterModel: ExtendedGridFilterModel = body.filterModel; + console.log('filter model: ', filterModel); + if (!params.slugs || params.slugs.length < 5) throw new Error('slugs not received.'); + const [schema, pageParam, pageSizeParam, plotIDParam, plotCensusNumberParam] = params.slugs; + if (!schema || schema === 'undefined' || !pageParam || pageParam === 'undefined' || !pageSizeParam || pageSizeParam === 'undefined') + throw new Error('core slugs schema/page/pageSize not correctly received'); + if (!filterModel || (!filterModel.items && !filterModel.quickFilterValues)) throw new Error('filterModel is empty. filter API should not have triggered.'); + const page = parseInt(pageParam); + const pageSize = parseInt(pageSizeParam); + const plotID = plotIDParam ? parseInt(plotIDParam) : undefined; + const plotCensusNumber = plotCensusNumberParam ? parseInt(plotCensusNumberParam) : undefined; + const connectionManager = new ConnectionManager(); + let updatedMeasurementsExist = false; + let censusIDs; + let pastCensusIDs: string | any[]; + + const buildFilterModelStub = (filterModel: GridFilterModel, alias?: string) => { + if (!filterModel.items || filterModel.items.length === 0) { + return ''; + } + + return filterModel.items + .map((item: GridFilterItem) => { + const { field, operator, value } = item; + const aliasedField = `${alias ? `${alias}.` : ''}${field}`; + const escapedValue = escape(`%${value}%`); // Handle escaping + return `${aliasedField} ${operator} ${escapedValue}`; + }) + .join(` ${filterModel?.logicOperator?.toUpperCase() || 'AND'} `); + }; + + const buildSearchStub = (columns: string[], quickFilter: string[], alias?: string) => { + if (!quickFilter || quickFilter.length === 0) { + return ''; // Return empty if no quick filters + } + + return columns + .map(column => { + const aliasedColumn = `${alias ? `${alias}.` : ''}${column}`; + return quickFilter.map(word => `${aliasedColumn} LIKE ${escape(`%${word}%`)}`).join(' OR '); + }) + .join(' OR '); + }; + + try { + let paginatedQuery = ``; + const queryParams: any[] = []; + let columns: any[] = []; + try { + const query = `SELECT COLUMN_NAME FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = ? AND TABLE_NAME = ? + AND COLUMN_NAME NOT LIKE '%id%' AND COLUMN_NAME NOT LIKE '%uuid%' AND COLUMN_NAME NOT LIKE 'id%' AND COLUMN_NAME NOT LIKE '%_id' `; + const results = await connectionManager.executeQuery(query, [schema, params.dataType]); + columns = results.map((row: any) => row.COLUMN_NAME); + } catch (e: any) { + console.log('error: ', e); + throw new Error(e); + } + let searchStub = ''; + let filterStub = ''; + switch (params.dataType) { + case 'validationprocedures': + if (filterModel.quickFilterValues) searchStub = buildSearchStub(columns, filterModel.quickFilterValues); + if (filterModel.items) filterStub = buildFilterModelStub(filterModel); + + paginatedQuery = ` + SELECT SQL_CALC_FOUND_ROWS * FROM catalog.${params.dataType} + ${searchStub || filterStub ? ` WHERE (${[searchStub, filterStub].filter(Boolean).join(' OR ')})` : ''}`; // validation procedures is special + queryParams.push(page * pageSize, pageSize); + break; + case 'attributes': + case 'species': + case 'stems': + case 'alltaxonomiesview': + case 'quadratpersonnel': + case 'sitespecificvalidations': + case 'roles': + if (filterModel.quickFilterValues) searchStub = buildSearchStub(columns, filterModel.quickFilterValues); + if (filterModel.items) filterStub = buildFilterModelStub(filterModel); + + paginatedQuery = `SELECT SQL_CALC_FOUND_ROWS * FROM ${schema}.${params.dataType} + ${searchStub || filterStub ? ` WHERE (${[searchStub, filterStub].filter(Boolean).join(' OR ')})` : ''}`; + queryParams.push(page * pageSize, pageSize); + break; + case 'personnel': + if (filterModel.quickFilterValues) searchStub = buildSearchStub(columns, filterModel.quickFilterValues, 'p'); + if (filterModel.items) filterStub = buildFilterModelStub(filterModel, 'p'); + + paginatedQuery = ` + SELECT SQL_CALC_FOUND_ROWS p.* + FROM ${schema}.${params.dataType} p + JOIN ${schema}.census c ON p.CensusID = c.CensusID + WHERE c.PlotID = ? + AND c.PlotCensusNumber = ? + ${searchStub || filterStub ? ` AND (${[searchStub, filterStub].filter(Boolean).join(' OR ')})` : ''}`; + queryParams.push(plotID, plotCensusNumber, page * pageSize, pageSize); + break; + case 'quadrats': + if (filterModel.quickFilterValues) searchStub = buildSearchStub(columns, filterModel.quickFilterValues, 'q'); + if (filterModel.items) filterStub = buildFilterModelStub(filterModel, 'q'); + + paginatedQuery = ` + SELECT SQL_CALC_FOUND_ROWS q.* + FROM ${schema}.quadrats q + JOIN ${schema}.censusquadrat cq ON q.QuadratID = cq.QuadratID + JOIN ${schema}.census c ON cq.CensusID = c.CensusID + WHERE q.PlotID = ? + AND c.PlotID = ? + AND c.PlotCensusNumber = ? + ${searchStub || filterStub ? ` AND (${[searchStub, filterStub].filter(Boolean).join(' OR ')})` : ''}`; + queryParams.push(plotID, plotID, plotCensusNumber, page * pageSize, pageSize); + break; + case 'personnelrole': + if (filterModel.quickFilterValues) searchStub = buildSearchStub(columns, filterModel.quickFilterValues, 'p'); + if (filterModel.items) filterStub = buildFilterModelStub(filterModel, 'p'); + + paginatedQuery = ` + SELECT SQL_CALC_FOUND_ROWS + p.PersonnelID, + p.CensusID, + p.FirstName, + p.LastName, + r.RoleName, + r.RoleDescription + FROM + personnel p + LEFT JOIN + roles r ON p.RoleID = r.RoleID + census c ON p.CensusID = c.CensusID + WHERE c.PlotID = ? + AND c.PlotCensusNumber = ? + ${searchStub || filterStub ? ` AND (${[searchStub, filterStub].filter(Boolean).join(' OR ')})` : ''}`; + queryParams.push(plotID, plotCensusNumber, page * pageSize, pageSize); + break; + case 'census': + if (filterModel.quickFilterValues) searchStub = buildSearchStub(columns, filterModel.quickFilterValues); + if (filterModel.items) filterStub = buildFilterModelStub(filterModel); + + paginatedQuery = ` + SELECT SQL_CALC_FOUND_ROWS * + FROM ${schema}.census + WHERE PlotID = ? + ${searchStub || filterStub ? ` AND (${[searchStub, filterStub].filter(Boolean).join(' OR ')})` : ''}`; + queryParams.push(plotID, page * pageSize, pageSize); + break; + case 'measurementssummary': + case 'measurementssummary_staging': + case 'measurementssummaryview': + case 'viewfulltable': + case 'viewfulltableview': + if (filterModel.quickFilterValues) searchStub = buildSearchStub(columns, filterModel.quickFilterValues, 'vft'); + if (filterModel.items) filterStub = buildFilterModelStub(filterModel, 'vft'); + + paginatedQuery = ` + SELECT SQL_CALC_FOUND_ROWS vft.* + FROM ${schema}.${params.dataType} vft + JOIN ${schema}.census c ON vft.PlotID = c.PlotID AND vft.CensusID = c.CensusID + WHERE vft.PlotID = ? + AND c.PlotID = ? + AND c.PlotCensusNumber = ? + ${ + filterModel.visible.length > 0 + ? ` AND (${filterModel.visible + .map(v => { + switch (v) { + case 'valid': + return `vft.IsValidated = TRUE`; + case 'errors': + return `vft.IsValidated = FALSE`; + case 'pending': + return `vft.IsValidated IS NULL`; + default: + return null; + } + }) + .filter(Boolean) + .join(' OR ')})` + : '' + } + ${searchStub || filterStub ? ` AND (${[searchStub, filterStub].filter(Boolean).join(' OR ')})` : ''} + ORDER BY vft.MeasurementDate ASC`; + queryParams.push(plotID, plotID, plotCensusNumber, page * pageSize, pageSize); + break; + case 'coremeasurements': + if (filterModel.quickFilterValues) searchStub = buildSearchStub(columns, filterModel.quickFilterValues, 'pdt'); + if (filterModel.items) filterStub = buildFilterModelStub(filterModel, 'pdt'); + + const censusQuery = ` + SELECT CensusID + FROM ${schema}.census + WHERE PlotID = ? + AND PlotCensusNumber = ? + ORDER BY StartDate DESC LIMIT 30 + `; + const censusResults = await connectionManager.executeQuery(format(censusQuery, [plotID, plotCensusNumber])); + if (censusResults.length < 2) { + paginatedQuery = ` + SELECT SQL_CALC_FOUND_ROWS pdt.* + FROM ${schema}.${params.dataType} pdt + JOIN ${schema}.census c ON pdt.CensusID = c.CensusID + WHERE c.PlotID = ? + AND c.PlotCensusNumber = ? AND (${searchStub} ${filterStub !== '' ? `OR ${filterStub}` : ``}) + ORDER BY pdt.MeasurementDate`; + queryParams.push(plotID, plotCensusNumber, page * pageSize, pageSize); + break; + } else { + updatedMeasurementsExist = true; + censusIDs = censusResults.map((c: any) => c.CensusID); + pastCensusIDs = censusIDs.slice(1); + paginatedQuery = ` + SELECT SQL_CALC_FOUND_ROWS pdt.* + FROM ${schema}.${params.dataType} pdt + JOIN ${schema}.census c ON sp.CensusID = c.CensusID + WHERE c.PlotID = ? + AND c.CensusID IN (${censusIDs.map(() => '?').join(', ')}) + ${searchStub || filterStub ? ` AND (${[searchStub, filterStub].filter(Boolean).join(' OR ')})` : ''} + ORDER BY pdt.MeasurementDate ASC`; + queryParams.push(plotID, ...censusIDs, page * pageSize, pageSize); + break; + } + default: + throw new Error(`Unknown dataType: ${params.dataType}`); + } + paginatedQuery += ` LIMIT ?, ?;`; + + if (paginatedQuery.match(/\?/g)?.length !== queryParams.length) { + throw new Error( + `Mismatch between query placeholders and parameters: paginated query length: ${paginatedQuery.match(/\?/g)?.length}, parameters length: ${queryParams.length}` + ); + } + console.log('completed query: ', format(paginatedQuery, queryParams)); + const paginatedResults = await connectionManager.executeQuery(format(paginatedQuery, queryParams)); + + const totalRowsQuery = 'SELECT FOUND_ROWS() as totalRows'; + const totalRowsResult = await connectionManager.executeQuery(totalRowsQuery); + const totalRows = totalRowsResult[0].totalRows; + + if (updatedMeasurementsExist) { + const deprecated = paginatedResults.filter((row: any) => pastCensusIDs.includes(row.CensusID)); + + const uniqueKeys = ['PlotID', 'QuadratID', 'TreeID', 'StemID']; + const outputKeys = paginatedResults.map((row: any) => uniqueKeys.map(key => row[key]).join('|')); + const filteredDeprecated = deprecated.filter((row: any) => outputKeys.includes(uniqueKeys.map(key => row[key]).join('|'))); + return new NextResponse( + JSON.stringify({ + output: MapperFactory.getMapper(params.dataType).mapData(paginatedResults), + deprecated: MapperFactory.getMapper(params.dataType).mapData(filteredDeprecated), + totalCount: totalRows, + finishedQuery: format(paginatedQuery, queryParams) + }), + { status: HTTPResponses.OK } + ); + } else { + return new NextResponse( + JSON.stringify({ + output: MapperFactory.getMapper(params.dataType).mapData(paginatedResults), + deprecated: undefined, + totalCount: totalRows, + finishedQuery: format(paginatedQuery, queryParams) + }), + { status: HTTPResponses.OK } + ); + } + } catch (error: any) { + throw new Error(error); + } finally { + await connectionManager.closeConnection(); + } + } +} + +// slugs: schema, gridID +export async function PATCH(request: NextRequest, { params }: { params: { dataType: string; slugs?: string[] } }) { + if (!params.slugs) throw new Error('slugs not provided'); + const [schema, gridID] = params.slugs; + if (!schema || !gridID) throw new Error('no schema or gridID provided'); + + const connectionManager = new ConnectionManager(); + const demappedGridID = gridID.charAt(0).toUpperCase() + gridID.substring(1); + const { newRow, oldRow } = await request.json(); + let updateIDs: { [key: string]: number } = {}; + + try { + await connectionManager.beginTransaction(); + + // Handle views with handleUpsertForSlices (applies to both insert and update logic) + if (params.dataType === 'alltaxonomiesview') { + let queryConfig; + switch (params.dataType) { + case 'alltaxonomiesview': + queryConfig = AllTaxonomiesViewQueryConfig; + break; + default: + throw new Error('Incorrect view call'); + } + + // Use handleUpsertForSlices for update operations as well (updates where needed) + updateIDs = await handleUpsertForSlices(connectionManager, schema, newRow, queryConfig); + } + + // Handle non-view table updates + else { + const newRowData = MapperFactory.getMapper(params.dataType).demapData([newRow])[0]; + const { [demappedGridID]: gridIDKey, ...remainingProperties } = newRowData; + + // Construct the UPDATE query + const updateQuery = format( + `UPDATE ?? + SET ? + WHERE ?? = ?`, + [`${schema}.${params.dataType}`, remainingProperties, demappedGridID, gridIDKey] + ); + + // Execute the UPDATE query + await connectionManager.executeQuery(updateQuery); + + // For non-view tables, standardize the response format + updateIDs = { [params.dataType]: gridIDKey }; + } + + return NextResponse.json({ message: 'Update successful', updatedIDs: updateIDs }, { status: HTTPResponses.OK }); + } catch (error: any) { + return handleError(error, connectionManager, newRow); + } finally { + await connectionManager.closeConnection(); + } +} + +// slugs: schema, gridID +// body: full data row, only need first item from it this time though +export async function DELETE(request: NextRequest, { params }: { params: { dataType: string; slugs?: string[] } }) { + if (!params.slugs) throw new Error('slugs not provided'); + const [schema, gridID] = params.slugs; + if (!schema || !gridID) throw new Error('no schema or gridID provided'); + const connectionManager = new ConnectionManager(); + const demappedGridID = gridID.charAt(0).toUpperCase() + gridID.substring(1); + const { newRow } = await request.json(); + try { + await connectionManager.beginTransaction(); + + // Handle deletion for views + if (['alltaxonomiesview', 'measurementssummaryview'].includes(params.dataType)) { + const deleteRowData = MapperFactory.getMapper(params.dataType).demapData([newRow])[0]; + + // Prepare query configuration based on view + let queryConfig; + switch (params.dataType) { + case 'alltaxonomiesview': + queryConfig = AllTaxonomiesViewQueryConfig; + break; + default: + throw new Error('Incorrect view call'); + } + + // Use handleDeleteForSlices for handling deletion, taking foreign key constraints into account + await handleDeleteForSlices(connectionManager, schema, deleteRowData, queryConfig); + return NextResponse.json({ message: 'Delete successful' }, { status: HTTPResponses.OK }); + } + + // Handle deletion for tables + const deleteRowData = MapperFactory.getMapper(params.dataType).demapData([newRow])[0]; + const { [demappedGridID]: gridIDKey } = deleteRowData; + // for quadrats, censusquadrat needs to be cleared before quadrat can be deleted + if (params.dataType === 'quadrats') { + const qDeleteQuery = format(`DELETE FROM ?? WHERE ?? = ?`, [`${schema}.censusquadrat`, demappedGridID, gridIDKey]); + await connectionManager.executeQuery(qDeleteQuery); + } + const deleteQuery = format(`DELETE FROM ?? WHERE ?? = ?`, [`${schema}.${params.dataType}`, demappedGridID, gridIDKey]); + await connectionManager.executeQuery(deleteQuery); + return NextResponse.json({ message: 'Delete successful' }, { status: HTTPResponses.OK }); + } catch (error: any) { + if (error.code === 'ER_ROW_IS_REFERENCED_2') { + await connectionManager.rollbackTransaction(); + const referencingTableMatch = error.message.match(/CONSTRAINT `(.*?)` FOREIGN KEY \(`(.*?)`\) REFERENCES `(.*?)`/); + const referencingTable = referencingTableMatch ? referencingTableMatch[3] : 'unknown'; + return NextResponse.json( + { + message: 'Foreign key conflict detected', + referencingTable + }, + { status: HTTPResponses.FOREIGN_KEY_CONFLICT } + ); + } else return handleError(error, connectionManager, newRow); + } finally { + await connectionManager.closeConnection(); + } +} diff --git a/frontend/app/api/formdownload/[dataType]/[[...slugs]]/route.ts b/frontend/app/api/formdownload/[dataType]/[[...slugs]]/route.ts new file mode 100644 index 00000000..0a238aca --- /dev/null +++ b/frontend/app/api/formdownload/[dataType]/[[...slugs]]/route.ts @@ -0,0 +1,128 @@ +import { NextRequest, NextResponse } from 'next/server'; +import MapperFactory from '@/config/datamapper'; +import { AttributesRDS } from '@/config/sqlrdsdefinitions/core'; +import { HTTPResponses } from '@/config/macros'; +import ConnectionManager from '@/config/connectionmanager'; + +export async function GET(_request: NextRequest, { params }: { params: { dataType: string; slugs?: string[] } }) { + const { dataType, slugs } = params; + if (!dataType || !slugs) throw new Error('data type or slugs not provided'); + const [schema, plotIDParam, censusIDParam] = slugs; + if (!schema) throw new Error('no schema provided'); + + const plotID = plotIDParam ? parseInt(plotIDParam) : undefined; + const censusID = censusIDParam ? parseInt(censusIDParam) : undefined; + + const connectionManager = new ConnectionManager(); + let query: string = ''; + let results: any[] = []; + let mappedResults: any[] = []; + let formMappedResults: any[] = []; + try { + switch (dataType) { + case 'attributes': + query = `SELECT * FROM ${schema}.attributes`; + results = await connectionManager.executeQuery(query); + mappedResults = MapperFactory.getMapper('attributes').mapData(results); + formMappedResults = mappedResults.map((row: AttributesRDS) => ({ + code: row.code, + description: row.description, + status: row.status + })); + return new NextResponse(JSON.stringify(formMappedResults), { status: HTTPResponses.OK }); + case 'personnel': + query = `SELECT p.FirstName AS FirstName, p.LastName AS LastName, r.RoleName AS RoleName, r.RoleDescription AS RoleDescription + FROM ${schema}.personnel p + LEFT JOIN ${schema}.roles r ON p.RoleID = r.RoleID + LEFT JOIN ${schema}.census c ON c.CensusID = p.CensusID + WHERE c.PlotID = ? AND p.CensusID = ?`; + results = await connectionManager.executeQuery(query, [plotID, censusID]); + formMappedResults = results.map((row: any) => ({ + firstname: row.FirstName, + lastname: row.LastName, + role: row.RoleName, + roledescription: row.RoleDescription + })); + return new NextResponse(JSON.stringify(formMappedResults), { status: HTTPResponses.OK }); + case 'species': + query = `SELECT DISTINCT s.SpeciesCode AS SpeciesCode, f.Family AS Family, + g.Genus AS Genus, s.SpeciesName AS SpeciesName, s.SubspeciesName AS SubspeciesName, + s.IDLevel AS IDLevel, s.SpeciesAuthority AS SpeciesAuthority, s.SubspeciesAuthority AS SubspeciesAuthority + FROM ${schema}.species s + JOIN ${schema}.genus g ON g.GenusID = s.GenusID + JOIN ${schema}.family f ON f.FamilyID = g.FamilyID + JOIN ${schema}.trees t ON t.SpeciesID = s.SpeciesID + JOIN ${schema}.stems st ON st.TreeID = t.TreeID + JOIN ${schema}.quadrats q ON q.QuadratID = st.QuadratID + JOIN ${schema}.censusquadrat cq ON cq.QuadratID = q.QuadratID + WHERE q.PlotID = ? AND cq.CensusID = ?`; + results = await connectionManager.executeQuery(query, [plotID, censusID]); + formMappedResults = results.map((row: any) => ({ + spcode: row.SpeciesCode, + family: row.Family, + genus: row.Genus, + species: row.SpeciesName, + subspecies: row.SubspeciesName, + idlevel: row.IDLevel, + authority: row.SpeciesAuthority, + subspeciesauthority: row.SubspeciesAuthority + })); + return new NextResponse(JSON.stringify(formMappedResults), { status: HTTPResponses.OK }); + case 'quadrats': + query = `SELECT * FROM ${schema}.quadrats q + JOIN ${schema}.censusquadrat cq ON cq.QuadratID = q.QuadratID + WHERE q.PlotID = ? AND cq.CensusID = ?`; + results = await connectionManager.executeQuery(query, [plotID, censusID]); + formMappedResults = results.map((row: any) => ({ + quadrat: row.QuadratName, + startx: row.StartX, + starty: row.StartY, + coordinateunit: row.CoordinateUnits, + dimx: row.DimensionX, + dimy: row.DimensionY, + dimensionunit: row.DimensionUnits, + area: row.Area, + areaunit: row.AreaUnits, + quadratshape: row.QuadratShape + })); + return new NextResponse(JSON.stringify(formMappedResults), { status: HTTPResponses.OK }); + case 'measurements': + query = `SELECT st.StemTag AS StemTag, t.TreeTag AS TreeTag, s.SpeciesCode AS SpeciesCode, q.QuadratName AS QuadratName, + q.StartX AS StartX, q.StartY AS StartY, q.CoordinateUnits AS CoordinateUnits, cm.MeasuredDBH AS MeasuredDBH, cm.DBHUnit AS DBHUnit, + cm.MeasuredHOM AS MeasuredHOM, cm.HOMUnit AS HOMUnit, cm.MeasurementDate AS MeasurementDate, + (SELECT GROUP_CONCAT(ca.Code SEPARATOR '; ') + FROM ${schema}.cmattributes ca + WHERE ca.CoreMeasurementID = cm.CoreMeasurementID) AS Codes + FROM ${schema}.coremeasurements cm + JOIN ${schema}.stems st ON st.StemID = cm.StemID + JOIN ${schema}.trees t ON t.TreeID = st.TreeID + JOIN ${schema}.quadrats q ON q.QuadratID = st.QuadratID + JOIN ${schema}.censusquadrat cq ON cq.QuadratID = q.QuadratID + JOIN ${schema}.species s ON s.SpeciesID = t.SpeciesID + WHERE q.PlotID = ? AND cq.CensusID = ?`; + results = await connectionManager.executeQuery(query, [plotID, censusID]); + formMappedResults = results.map((row: any) => ({ + tag: row.TreeTag, + stemtag: row.StemTag, + spcode: row.SpeciesCode, + quadrat: row.QuadratName, + lx: row.StartX, + ly: row.StartY, + coordinateunit: row.CoordinateUnits, + dbh: row.MeasuredDBH, + dbhunit: row.DBHUnit, + hom: row.MeasuredHOM, + homunit: row.HOMUnit, + date: row.MeasurementDate, + codes: row.Codes + })); + return new NextResponse(JSON.stringify(formMappedResults), { status: HTTPResponses.OK }); + default: + throw new Error('incorrect data type passed in'); + } + } catch (e: any) { + throw new Error(e); + } finally { + await connectionManager.closeConnection(); + } +} diff --git a/frontend/app/api/formsearch/attributes/route.ts b/frontend/app/api/formsearch/attributes/route.ts deleted file mode 100644 index e4801463..00000000 --- a/frontend/app/api/formsearch/attributes/route.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { NextRequest, NextResponse } from 'next/server'; -import { getConn, runQuery } from '@/components/processors/processormacros'; -import { FORMSEARCH_LIMIT } from '@/config/macros/azurestorage'; -import { HTTPResponses } from '@/config/macros'; - -export async function GET(request: NextRequest): Promise> { - const schema = request.nextUrl.searchParams.get('schema'); - if (!schema || schema === 'undefined') throw new Error('no schema provided!'); - const partialCode = request.nextUrl.searchParams.get('searchfor')!; - const conn = await getConn(); - try { - const query = - partialCode === '' - ? `SELECT DISTINCT Code FROM ${schema}.attributes ORDER BY Code LIMIT ${FORMSEARCH_LIMIT}` - : `SELECT DISTINCT Code FROM ${schema}.attributes WHERE Code LIKE ? ORDER BY Code LIMIT ${FORMSEARCH_LIMIT}`; - const queryParams = partialCode === '' ? [] : [`%${partialCode}%`]; - const results = await runQuery(conn, query, queryParams); - - return new NextResponse(JSON.stringify(results.map((row: any) => row.Code)), { status: HTTPResponses.OK }); - } catch (error: any) { - console.error('Error in GET Attributes:', error.message || error); - throw new Error('Failed to fetch attribute data'); - } finally { - if (conn) conn.release(); - } -} diff --git a/frontend/app/api/formsearch/personnel/route.ts b/frontend/app/api/formsearch/personnel/route.ts deleted file mode 100644 index fe91e7aa..00000000 --- a/frontend/app/api/formsearch/personnel/route.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { NextRequest, NextResponse } from 'next/server'; -import { getConn, runQuery } from '@/components/processors/processormacros'; -import { FORMSEARCH_LIMIT } from '@/config/macros/azurestorage'; -import { HTTPResponses } from '@/config/macros'; - -export async function GET(request: NextRequest): Promise> { - const schema = request.nextUrl.searchParams.get('schema'); - if (!schema || schema === 'undefined') throw new Error('no schema provided!'); - const partialLastName = request.nextUrl.searchParams.get('searchfor')!; - const conn = await getConn(); - try { - const query = - partialLastName === '' - ? `SELECT FirstName, LastName - FROM ${schema}.personnel - ORDER BY LastName - LIMIT ${FORMSEARCH_LIMIT}` - : `SELECT FirstName, LastName - FROM ${schema}.personnel - WHERE LastName LIKE ? - ORDER BY LastName - LIMIT ${FORMSEARCH_LIMIT}`; - const queryParams = partialLastName === '' ? [] : [`%${partialLastName}%`]; - const results = await runQuery(conn, query, queryParams); - - // Properly mapping results to return an array of { label, code } - return new NextResponse(JSON.stringify(results.map((row: any) => `${row.FirstName} ${row.LastName}`)), { status: HTTPResponses.OK }); - } catch (error: any) { - console.error('Error in GET Personnel:', error.message || error); - throw new Error('Failed to fetch personnel data'); - } finally { - if (conn) conn.release(); - } -} diff --git a/frontend/app/api/formsearch/personnelblock/route.ts b/frontend/app/api/formsearch/personnelblock/route.ts deleted file mode 100644 index 192ef806..00000000 --- a/frontend/app/api/formsearch/personnelblock/route.ts +++ /dev/null @@ -1,90 +0,0 @@ -import { NextRequest, NextResponse } from 'next/server'; -import { PoolConnection } from 'mysql2/promise'; -import { getConn, runQuery } from '@/components/processors/processormacros'; -import { FORMSEARCH_LIMIT } from '@/config/macros/azurestorage'; -import { HTTPResponses } from '@/config/macros'; -import { PersonnelRDS, PersonnelResult } from '@/config/sqlrdsdefinitions/personnel'; - -export async function GET(request: NextRequest): Promise> { - const schema = request.nextUrl.searchParams.get('schema'); - if (!schema) throw new Error('no schema provided!'); - const partialLastName = request.nextUrl.searchParams.get('searchfor')!; - const conn = await getConn(); - try { - const query = - partialLastName === '' - ? `SELECT DISTINCT PersonnelID, FirstName, LastName, Role - FROM ${schema}.personnel - ORDER BY LastName - LIMIT ${FORMSEARCH_LIMIT}` - : `SELECT DISTINCT PersonnelID, FirstName, LastName, Role - FROM ${schema}.personnel - WHERE LastName LIKE ? - ORDER BY LastName - LIMIT ${FORMSEARCH_LIMIT}`; - const queryParams = partialLastName === '' ? [] : [`%${partialLastName}%`]; - const results = await runQuery(conn, query, queryParams); - - const personnelRows: PersonnelRDS[] = results.map((row: PersonnelResult, index: number) => ({ - id: index + 1, - personnelID: row.PersonnelID, - firstName: row.FirstName, - lastName: row.LastName, - roleID: row.RoleID - })); - - // Properly mapping results to return an array of { label, code } - return new NextResponse(JSON.stringify(personnelRows), { - status: HTTPResponses.OK - }); - } catch (error: any) { - console.error('Error in GET Personnel:', error.message || error); - throw new Error('Failed to fetch personnel data'); - } finally { - if (conn) conn.release(); - } -} - -export async function PUT(request: NextRequest): Promise { - let conn: PoolConnection | null = null; - const schema = request.nextUrl.searchParams.get('schema'); - const quadratID = parseInt(request.nextUrl.searchParams.get('quadratID')!, 10); - if (!schema || schema === 'undefined' || isNaN(quadratID)) throw new Error('Missing required parameters'); - - try { - const updatedPersonnelIDs: number[] = await request.json(); - - conn = await getConn(); - await conn.beginTransaction(); - - // Fetch current personnel IDs - const currentPersonnelQuery = `SELECT PersonnelID FROM ${schema}.quadratpersonnel WHERE QuadratID = ?`; - const currentPersonnelResult: { PersonnelID: number }[] = await runQuery(conn, currentPersonnelQuery, [quadratID]); - const currentPersonnelIds = currentPersonnelResult.map(p => p.PersonnelID); - - // Determine personnel to add or remove - const personnelToAdd = updatedPersonnelIDs.filter(id => !currentPersonnelIds.includes(id)); - const personnelToRemove = currentPersonnelIds.filter(id => !updatedPersonnelIDs.includes(id)); - - // Remove personnel - for (const personnelId of personnelToRemove) { - await runQuery(conn, `DELETE FROM ${schema}.quadratpersonnel WHERE QuadratID = ? AND PersonnelID = ?`, [quadratID, personnelId]); - } - - // Add new personnel associations - for (const personnelId of personnelToAdd) { - await runQuery(conn, `INSERT INTO ${schema}.quadratpersonnel (QuadratID, PersonnelID) VALUES (?, ?)`, [quadratID, personnelId]); - } - - // Commit the transaction - await conn.commit(); - - return NextResponse.json({ message: 'Personnel updated successfully' }, { status: HTTPResponses.OK }); - } catch (error) { - await conn?.rollback(); - console.error('Error:', error); - throw new Error('Personnel update failed'); - } finally { - if (conn) conn.release(); - } -} diff --git a/frontend/app/api/formsearch/quadrats/route.ts b/frontend/app/api/formsearch/quadrats/route.ts deleted file mode 100644 index fc1fbc91..00000000 --- a/frontend/app/api/formsearch/quadrats/route.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { NextRequest, NextResponse } from 'next/server'; -import { getConn, runQuery } from '@/components/processors/processormacros'; -import { FORMSEARCH_LIMIT } from '@/config/macros/azurestorage'; -import { HTTPResponses } from '@/config/macros'; - -export async function GET(request: NextRequest): Promise> { - const schema = request.nextUrl.searchParams.get('schema'); - if (!schema || schema === 'undefined') throw new Error('no schema provided!'); - const partialQuadratName = request.nextUrl.searchParams.get('searchfor')!; - const conn = await getConn(); - try { - const query = - partialQuadratName === '' - ? `SELECT QuadratName - FROM ${schema}.quadrats - ORDER BY QuadratName - LIMIT ${FORMSEARCH_LIMIT}` - : `SELECT QuadratName - FROM ${schema}.quadrats - WHERE QuadratName LIKE ? - ORDER BY QuadratName - LIMIT ${FORMSEARCH_LIMIT}`; - const queryParams = partialQuadratName === '' ? [] : [`%${partialQuadratName}%`]; - const results = await runQuery(conn, query, queryParams); - return new NextResponse(JSON.stringify(results.map((row: any) => row.QuadratName)), { status: HTTPResponses.OK }); - } catch (error: any) { - console.error('Error in GET Quadrats:', error.message || error); - throw new Error('Failed to fetch quadrat data'); - } finally { - if (conn) conn.release(); - } -} diff --git a/frontend/app/api/formsearch/species/route.ts b/frontend/app/api/formsearch/species/route.ts deleted file mode 100644 index 55189288..00000000 --- a/frontend/app/api/formsearch/species/route.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { NextRequest, NextResponse } from 'next/server'; -import { getConn, runQuery } from '@/components/processors/processormacros'; -import { FORMSEARCH_LIMIT } from '@/config/macros/azurestorage'; -import { HTTPResponses } from '@/config/macros'; - -export async function GET(request: NextRequest): Promise> { - const schema = request.nextUrl.searchParams.get('schema'); - if (!schema || schema === 'undefined') throw new Error('no schema provided!'); - const partialSpeciesCode = request.nextUrl.searchParams.get('searchfor')!; - const conn = await getConn(); - try { - const query = - partialSpeciesCode === '' - ? `SELECT SpeciesCode - FROM ${schema}.species - ORDER BY SpeciesCode - LIMIT ${FORMSEARCH_LIMIT}` - : `SELECT SpeciesCode - FROM ${schema}.species - WHERE SpeciesCode LIKE ? - ORDER BY SpeciesCode - LIMIT ${FORMSEARCH_LIMIT}`; - const queryParams = partialSpeciesCode === '' ? [] : [`%${partialSpeciesCode}%`]; - const results = await runQuery(conn, query, queryParams); - return new NextResponse(JSON.stringify(results.map((row: any) => row.SpeciesCode)), { status: HTTPResponses.OK }); - } catch (error: any) { - console.error('Error in GET Quadrats:', error.message || error); - throw new Error('Failed to fetch quadrat data'); - } finally { - if (conn) conn.release(); - } -} diff --git a/frontend/app/api/formsearch/stems/route.ts b/frontend/app/api/formsearch/stems/route.ts deleted file mode 100644 index 4e81f4cf..00000000 --- a/frontend/app/api/formsearch/stems/route.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { NextRequest, NextResponse } from 'next/server'; -import { getConn, runQuery } from '@/components/processors/processormacros'; -import { FORMSEARCH_LIMIT } from '@/config/macros/azurestorage'; -import { HTTPResponses } from '@/config/macros'; - -export async function GET(request: NextRequest): Promise> { - const schema = request.nextUrl.searchParams.get('schema'); - if (!schema || schema === 'undefined') throw new Error('no schema provided!'); - const partialStemTag = request.nextUrl.searchParams.get('searchfor')!; - const conn = await getConn(); - try { - const query = - partialStemTag === '' - ? `SELECT StemTag - FROM ${schema}.stems - ORDER BY StemTag - LIMIT ${FORMSEARCH_LIMIT}` - : `SELECT StemTag - FROM ${schema}.stems - WHERE StemTag LIKE ? - ORDER BY StemTag - LIMIT ${FORMSEARCH_LIMIT}`; - const queryParams = partialStemTag === '' ? [] : [`%${partialStemTag}%`]; - const results = await runQuery(conn, query, queryParams); - return new NextResponse(JSON.stringify(results.map((row: any) => (row.StemTag ? row.StemTag : ''))), { status: HTTPResponses.OK }); - } catch (error: any) { - console.error('Error in GET Quadrats:', error.message || error); - throw new Error('Failed to fetch quadrat data'); - } finally { - if (conn) conn.release(); - } -} diff --git a/frontend/app/api/formsearch/trees/route.ts b/frontend/app/api/formsearch/trees/route.ts deleted file mode 100644 index 95eea4f4..00000000 --- a/frontend/app/api/formsearch/trees/route.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { NextRequest, NextResponse } from 'next/server'; -import { getConn, runQuery } from '@/components/processors/processormacros'; -import { FORMSEARCH_LIMIT } from '@/config/macros/azurestorage'; -import { HTTPResponses } from '@/config/macros'; - -export async function GET(request: NextRequest): Promise> { - const schema = request.nextUrl.searchParams.get('schema'); - if (!schema || schema === 'undefined') throw new Error('no schema provided!'); - const partialTreeTag = request.nextUrl.searchParams.get('searchfor')!; - const conn = await getConn(); - try { - const query = - partialTreeTag === '' - ? `SELECT TreeTag - FROM ${schema}.trees - ORDER BY TreeTag - LIMIT ${FORMSEARCH_LIMIT}` - : `SELECT TreeTag - FROM ${schema}.trees - WHERE TreeTag LIKE ? - ORDER BY TreeTag - LIMIT ${FORMSEARCH_LIMIT}`; - const queryParams = partialTreeTag === '' ? [] : [`%${partialTreeTag}%`]; - const results = await runQuery(conn, query, queryParams); - return new NextResponse(JSON.stringify(results.map((row: any) => row.TreeTag)), { status: HTTPResponses.OK }); - } catch (error: any) { - console.error('Error in GET Quadrats:', error.message || error); - throw new Error('Failed to fetch quadrat data'); - } finally { - if (conn) conn.release(); - } -} diff --git a/frontend/app/api/formvalidation/[dataType]/[[...slugs]]/route.ts b/frontend/app/api/formvalidation/[dataType]/[[...slugs]]/route.ts index f9122cd0..4b01f793 100644 --- a/frontend/app/api/formvalidation/[dataType]/[[...slugs]]/route.ts +++ b/frontend/app/api/formvalidation/[dataType]/[[...slugs]]/route.ts @@ -1,7 +1,7 @@ -import { getConn, runQuery } from '@/components/processors/processormacros'; import { HTTPResponses } from '@/config/macros'; -import { format, PoolConnection } from 'mysql2/promise'; +import { format } from 'mysql2/promise'; import { NextRequest, NextResponse } from 'next/server'; +import ConnectionManager from '@/config/connectionmanager'; // dataType // slugs: schema, columnName, value ONLY @@ -16,18 +16,17 @@ export async function GET(request: NextRequest, { params }: { params: { dataType if (!schema || !columnName || !value) return new NextResponse(null, { status: 404 }); - let conn: PoolConnection | null = null; + const connectionManager = new ConnectionManager(); try { - conn = await getConn(); const query = `SELECT 1 FROM ?? WHERE ?? = ? LIMIT 1`; const formatted = format(query, [`${schema}.${params.dataType}`, columnName, value]); - const results = await runQuery(conn, formatted); + const results = await connectionManager.executeQuery(formatted); if (results.length === 0) return new NextResponse(null, { status: 404 }); return new NextResponse(null, { status: HTTPResponses.OK }); } catch (error: any) { console.error(error); throw error; } finally { - if (conn) conn.release(); + await connectionManager.closeConnection(); } } diff --git a/frontend/app/api/postvalidation/route.ts b/frontend/app/api/postvalidation/route.ts index 0df42b31..5493e7d6 100644 --- a/frontend/app/api/postvalidation/route.ts +++ b/frontend/app/api/postvalidation/route.ts @@ -1,27 +1,33 @@ import { NextRequest, NextResponse } from 'next/server'; -import { getConn, runQuery } from '@/components/processors/processormacros'; import { HTTPResponses } from '@/config/macros'; +import ConnectionManager from '@/config/connectionmanager'; export async function GET(request: NextRequest) { const schema = request.nextUrl.searchParams.get('schema'); if (!schema) throw new Error('no schema variable provided!'); - const conn = await getConn(); - const query = `SELECT QueryID, QueryName, Description FROM ${schema}.postvalidationqueries WHERE IsEnabled IS TRUE;`; - const results = await runQuery(conn, query); - if (results.length === 0) { - return new NextResponse(JSON.stringify({ message: 'No queries found' }), { - status: HTTPResponses.NOT_FOUND + const connectionManager = new ConnectionManager(); + try { + const query = `SELECT QueryID, QueryName, Description FROM ${schema}.postvalidationqueries WHERE IsEnabled IS TRUE;`; + const results = await connectionManager.executeQuery(query); + if (results.length === 0) { + return new NextResponse(JSON.stringify({ message: 'No queries found' }), { + status: HTTPResponses.NOT_FOUND + }); + } + const postValidations = results.map((row: any) => ({ + queryID: row.QueryID, + queryName: row.QueryName, + queryDescription: row.Description + })); + return new NextResponse(JSON.stringify(postValidations), { + status: HTTPResponses.OK }); + } catch (e: any) { + throw e; + } finally { + await connectionManager.closeConnection(); } - const postValidations = results.map((row: any) => ({ - queryID: row.QueryID, - queryName: row.QueryName, - queryDescription: row.Description - })); - return new NextResponse(JSON.stringify(postValidations), { - status: HTTPResponses.OK - }); } // searchParams: schema, plot, census @@ -58,7 +64,7 @@ export async function GET(request: NextRequest) { // JOIN ${schema}.stems s ON s.TreeID = t.TreeID // JOIN ${schema}.quadrats q ON q.QuadratID = s.QuadratID // WHERE q.CensusID = ${currentCensusID} AND q.PlotID = ${currentPlotID};`, -// countNumDeadMissingByCensus: `SELECT cm.CensusID, COUNT(s.StemID) AS DeadOrMissingStems +// countNumDeadMissingByCensus: `SELECT s.StemID, COUNT(s.StemID) AS DeadOrMissingStems // FROM ${schema}.stems s // JOIN ${schema}.cmattributes cma ON s.StemID = cma.CoreMeasurementID // JOIN ${schema}.attributes a ON cma.Code = a.Code diff --git a/frontend/app/api/postvalidationbyquery/[schema]/[plotID]/[censusID]/[queryID]/route.ts b/frontend/app/api/postvalidationbyquery/[schema]/[plotID]/[censusID]/[queryID]/route.ts index 90a100dc..05a1052c 100644 --- a/frontend/app/api/postvalidationbyquery/[schema]/[plotID]/[censusID]/[queryID]/route.ts +++ b/frontend/app/api/postvalidationbyquery/[schema]/[plotID]/[censusID]/[queryID]/route.ts @@ -1,36 +1,57 @@ import { NextRequest, NextResponse } from 'next/server'; import { HTTPResponses } from '@/config/macros'; -import { getConn, runQuery } from '@/components/processors/processormacros'; +import moment from 'moment'; +import ConnectionManager from '@/config/connectionmanager'; export async function GET(_request: NextRequest, { params }: { params: { schema: string; plotID: string; censusID: string; queryID: string } }) { const { schema } = params; const plotID = parseInt(params.plotID); const censusID = parseInt(params.censusID); const queryID = parseInt(params.queryID); + if (!schema || !plotID || !censusID || !queryID) { return new NextResponse('Missing parameters', { status: HTTPResponses.INVALID_REQUEST }); } - const conn = await getConn(); - const query = `SELECT QueryDefinition FROM ${schema}.postvalidationqueries WHERE QueryID = ${queryID}`; - const results = await runQuery(conn, query); - if (results.length === 0) { - return new NextResponse('Query not found', { status: HTTPResponses.NOT_FOUND }); - } - const replacements = { - schema: schema, - currentPlotID: plotID, - currentCensusID: censusID - }; - const formattedQuery = results[0].QueryDefinition.replace(/\${(.*?)}/g, (_match: any, p1: string) => replacements[p1 as keyof typeof replacements]); - const queryResults = await runQuery(conn, formattedQuery); - if (queryResults.length === 0) { - return new NextResponse('Query returned no results', { status: HTTPResponses.NOT_FOUND }); + + const connectionManager = new ConnectionManager(); + try { + const query = `SELECT QueryDefinition FROM ${schema}.postvalidationqueries WHERE QueryID = ${queryID}`; + const results = await connectionManager.executeQuery(query); + + if (results.length === 0) { + return new NextResponse('Query not found', { status: HTTPResponses.NOT_FOUND }); + } + + const replacements = { + schema: schema, + currentPlotID: plotID, + currentCensusID: censusID + }; + const formattedQuery = results[0].QueryDefinition.replace(/\${(.*?)}/g, (_match: any, p1: string) => replacements[p1 as keyof typeof replacements]); + await connectionManager.beginTransaction(); + const queryResults = await connectionManager.executeQuery(formattedQuery); + + if (queryResults.length === 0) throw new Error('failure'); + + const currentTime = moment().format('YYYY-MM-DD HH:mm:ss'); + const successResults = JSON.stringify(queryResults); + const successUpdate = `UPDATE ${schema}.postvalidationqueries + SET LastRunAt = ?, LastRunResult = ?, LastRunStatus = 'success' + WHERE QueryID = ${queryID}`; + await connectionManager.executeQuery(successUpdate, [currentTime, successResults]); + return new NextResponse(null, { status: HTTPResponses.OK }); + } catch (e: any) { + await connectionManager.rollbackTransaction(); + if (e.message === 'failure') { + const currentTime = moment().format('YYYY-MM-DD HH:mm:ss'); + const failureUpdate = `UPDATE ${schema}.postvalidationqueries + SET LastRunAt = ?, LastRunStatus = 'failure' + WHERE QueryID = ${queryID}`; + await connectionManager.executeQuery(failureUpdate, [currentTime]); + return new NextResponse(null, { status: HTTPResponses.OK }); // if the query itself fails, that isn't a good enough reason to return a crash. It should just be logged. + } + return new NextResponse('Internal Server Error', { status: HTTPResponses.INTERNAL_SERVER_ERROR }); + } finally { + await connectionManager.closeConnection(); } - return new NextResponse( - JSON.stringify({ - count: queryResults.length, - data: queryResults - }), - { status: HTTPResponses.OK } - ); } diff --git a/frontend/app/api/refreshviews/[view]/[schema]/route.ts b/frontend/app/api/refreshviews/[view]/[schema]/route.ts index 24879ff2..a01e4e2d 100644 --- a/frontend/app/api/refreshviews/[view]/[schema]/route.ts +++ b/frontend/app/api/refreshviews/[view]/[schema]/route.ts @@ -1,21 +1,21 @@ -import { getConn, runQuery } from '@/components/processors/processormacros'; import { HTTPResponses } from '@/config/macros'; -import { PoolConnection } from 'mysql2/promise'; import { NextRequest, NextResponse } from 'next/server'; +import ConnectionManager from '@/config/connectionmanager'; export async function POST(_request: NextRequest, { params }: { params: { view: string; schema: string } }) { if (!params.schema || params.schema === 'undefined' || !params.view || params.view === 'undefined' || !params) throw new Error('schema not provided'); const { view, schema } = params; - let connection: PoolConnection | null = null; + const connectionManager = new ConnectionManager(); try { - connection = await getConn(); + await connectionManager.beginTransaction(); const query = `CALL ${schema}.Refresh${view === 'viewfulltable' ? 'ViewFullTable' : view === 'measurementssummary' ? 'MeasurementsSummary' : ''}();`; - await runQuery(connection, query); + await connectionManager.executeQuery(query); return new NextResponse(null, { status: HTTPResponses.OK }); } catch (e: any) { + await connectionManager.rollbackTransaction(); console.error('Error:', e); throw new Error('Call failed: ', e); } finally { - if (connection) connection.release(); + await connectionManager.closeConnection(); } } diff --git a/frontend/app/api/rollover/[dataType]/[[...slugs]]/route.ts b/frontend/app/api/rollover/[dataType]/[[...slugs]]/route.ts index 4374cb07..f97cb332 100644 --- a/frontend/app/api/rollover/[dataType]/[[...slugs]]/route.ts +++ b/frontend/app/api/rollover/[dataType]/[[...slugs]]/route.ts @@ -1,7 +1,6 @@ -import { getConn, runQuery } from '@/components/processors/processormacros'; import { HTTPResponses } from '@/config/macros'; -import { PoolConnection } from 'mysql2/promise'; import { NextRequest, NextResponse } from 'next/server'; +import ConnectionManager from '@/config/connectionmanager'; /** * Handles the POST request for the rollover API endpoint, which allows users to roll over quadrat or personnel data from one census to another within a specified schema. @@ -15,29 +14,28 @@ export async function POST(request: NextRequest, { params }: { params: { dataTyp const [schema, plotID, sourceCensusID, newCensusID] = params.slugs; if (!schema || !plotID || !sourceCensusID || !newCensusID) throw new Error('no schema or plotID or censusID provided'); - let conn: PoolConnection | null = null; + const connectionManager = new ConnectionManager(); try { const { incoming } = await request.json(); if (!Array.isArray(incoming) || incoming.length === 0) throw new Error('No quadrat or personnel IDs provided'); - conn = await getConn(); - if (conn) console.log('connection created.'); + if (connectionManager) console.log('connection created.'); let query = ``; let queryParams = []; - await conn.beginTransaction(); + await connectionManager.beginTransaction(); console.log('transaction started.'); switch (params.dataType) { case 'quadrats': query = ` - INSERT INTO censusquadrat (CensusID, QuadratID) + INSERT INTO ${schema}.censusquadrat (CensusID, QuadratID) SELECT ?, q.QuadratID - FROM quadrats q + FROM ${schema}.quadrats q WHERE q.QuadratID IN (${incoming.map(() => '?').join(', ')});`; queryParams = [Number(newCensusID), ...incoming]; - await runQuery(conn, query, queryParams); + await connectionManager.executeQuery(query, queryParams); break; case 'personnel': query = ` @@ -50,30 +48,19 @@ export async function POST(request: NextRequest, { params }: { params: { dataTyp FROM ${schema}.personnel WHERE CensusID = ? AND PersonnelID IN (${incoming.map(() => '?').join(', ')});`; queryParams = [Number(newCensusID), Number(sourceCensusID), ...incoming]; - await runQuery(conn, query, queryParams); + await connectionManager.executeQuery(query, queryParams); break; default: throw new Error('Invalid data type'); } - await conn.commit(); // testing return new NextResponse(JSON.stringify({ message: 'Rollover successful' }), { status: HTTPResponses.OK }); } catch (error: any) { - await conn?.rollback(); + await connectionManager.rollbackTransaction(); console.error('Error in rollover API:', error.message); return new NextResponse(JSON.stringify({ error: error.message }), { status: 500 }); } finally { - if (conn) conn.release(); + await connectionManager.closeConnection(); } } - -/** - * Handles the POST request for the rollover API endpoint, which allows users to rollover quadrat or personnel data from one census to another within a given schema. - * - * The slugs provided in the URL MUST include (in order): a schema, plotID, source censusID, and new censusID to target. - * - * @param request - The NextRequest object containing the request data. - * @param params - The URL parameters, including the dataType, schema, plotID, source censusID, and new censusID. - * @returns A NextResponse with a success message or an error message. - */ diff --git a/frontend/app/api/runquery/route.ts b/frontend/app/api/runquery/route.ts new file mode 100644 index 00000000..794bb940 --- /dev/null +++ b/frontend/app/api/runquery/route.ts @@ -0,0 +1,14 @@ +import { NextRequest, NextResponse } from 'next/server'; +import ConnectionManager from '@/config/connectionmanager'; + +// this is intended as a dedicated server-side execution pipeline for a given query. Results will be returned as-is to caller. +export async function POST(request: NextRequest) { + const query = await request.json(); // receiving query already formatted and prepped for execution + + const connectionManager = new ConnectionManager(); + const results = await connectionManager.executeQuery(query); + return new NextResponse(JSON.stringify(results), { + status: 200, + headers: { 'Content-Type': 'application/json' } + }); +} diff --git a/frontend/app/api/specieslimits/[speciesID]/route.ts b/frontend/app/api/specieslimits/[speciesID]/route.ts index 561acb64..7dac21cb 100644 --- a/frontend/app/api/specieslimits/[speciesID]/route.ts +++ b/frontend/app/api/specieslimits/[speciesID]/route.ts @@ -1,24 +1,22 @@ import { NextRequest, NextResponse } from 'next/server'; -import { PoolConnection } from 'mysql2/promise'; -import { getConn, runQuery } from '@/components/processors/processormacros'; import MapperFactory from '@/config/datamapper'; import { HTTPResponses } from '@/config/macros'; +import ConnectionManager from '@/config/connectionmanager'; export async function GET(request: NextRequest, { params }: { params: { speciesID: string } }) { const schema = request.nextUrl.searchParams.get('schema'); if (!schema) throw new Error('Schema not provided'); if (params.speciesID === 'undefined') throw new Error('SpeciesID not provided'); - let conn: PoolConnection | null = null; + const connectionManager = new ConnectionManager(); try { - conn = await getConn(); const query = `SELECT * FROM ${schema}.specieslimits WHERE SpeciesID = ?`; - const results = await runQuery(conn, query, [params.speciesID]); + const results = await connectionManager.executeQuery(query, [params.speciesID]); return new NextResponse(JSON.stringify(MapperFactory.getMapper('specieslimits').mapData(results)), { status: HTTPResponses.OK }); } catch (error: any) { throw new Error(error); } finally { - if (conn) conn.release(); + await connectionManager.closeConnection(); } } @@ -27,18 +25,18 @@ export async function PATCH(request: NextRequest, { params }: { params: { specie if (!schema) throw new Error('Schema not provided'); if (params.speciesID === 'undefined') throw new Error('SpeciesID not provided'); const { newRow } = await request.json(); - let conn: PoolConnection | null = null; + const connectionManager = new ConnectionManager(); try { - conn = await getConn(); - await conn.beginTransaction(); + await connectionManager.beginTransaction(); const newRowData = MapperFactory.getMapper('specieslimits').demapData([newRow])[0]; const { ['SpeciesLimitID']: gridIDKey, ...remainingProperties } = newRowData; const query = `UPDATE ${schema}.specieslimits SET ? WHERE ?? = ?`; - const results = await runQuery(conn, query, [remainingProperties, 'SpeciesLimitID', gridIDKey]); + await connectionManager.executeQuery(query, [remainingProperties, 'SpeciesLimitID', gridIDKey]); + return new NextResponse(null, { status: HTTPResponses.OK }); } catch (e: any) { - await conn?.rollback(); + await connectionManager.rollbackTransaction(); throw new Error(e); } finally { - if (conn) conn.release(); + await connectionManager.closeConnection(); } } diff --git a/frontend/app/api/sqlload/route.ts b/frontend/app/api/sqlload/route.ts index 3e82e070..c89efd52 100644 --- a/frontend/app/api/sqlload/route.ts +++ b/frontend/app/api/sqlload/route.ts @@ -1,9 +1,8 @@ import { NextRequest, NextResponse } from 'next/server'; -import { getConn, InsertUpdateProcessingProps } from '@/components/processors/processormacros'; -import { PoolConnection } from 'mysql2/promise'; -import { HTTPResponses } from '@/config/macros'; +import { HTTPResponses, InsertUpdateProcessingProps } from '@/config/macros'; import { FileRow, FileRowSet } from '@/config/macros/formdetails'; import { insertOrUpdate } from '@/components/processors/processorhelperfunctions'; +import ConnectionManager from '@/config/connectionmanager'; export async function POST(request: NextRequest) { const fileRowSet: FileRowSet = await request.json(); @@ -36,51 +35,18 @@ export async function POST(request: NextRequest) { // full name const fullName = request.nextUrl.searchParams.get('user') ?? undefined; - let connection: PoolConnection | null = null; // Use PoolConnection type - - try { - const i = 0; - connection = await getConn(); - } catch (error) { - if (error instanceof Error) { - console.error('Error processing files:', error.message); - return new NextResponse( - JSON.stringify({ - responseMessage: `Failure in connecting to SQL with ${error.message}`, - error: error.message - }), - { status: HTTPResponses.SQL_CONNECTION_FAILURE } - ); - } else { - console.error('Unknown error in connecting to SQL:', error); - return new NextResponse( - JSON.stringify({ - responseMessage: `Unknown SQL connection error with error: ${error}` - }), - { status: HTTPResponses.SQL_CONNECTION_FAILURE } - ); - } - } - - if (!connection) { - console.error('Container client or SQL connection is undefined.'); - return new NextResponse( - JSON.stringify({ - responseMessage: 'Container client or SQL connection is undefined' - }), - { status: HTTPResponses.SERVICE_UNAVAILABLE } - ); - } + const connectionManager = new ConnectionManager(); const idToRows: { coreMeasurementID: number; fileRow: FileRow }[] = []; for (const rowId in fileRowSet) { + await connectionManager.beginTransaction(); console.log(`rowID: ${rowId}`); const row = fileRowSet[rowId]; console.log('row for row ID: ', row); try { const props: InsertUpdateProcessingProps = { schema, - connection, + connectionManager: connectionManager, formType, rowData: row, plotID, @@ -94,7 +60,9 @@ export async function POST(request: NextRequest) { } else if (formType === 'measurements' && coreMeasurementID === undefined) { throw new Error('CoreMeasurement insertion failure at row: ' + row); } + await connectionManager.commitTransaction(); } catch (error) { + await connectionManager.rollbackTransaction(); if (error instanceof Error) { console.error(`Error processing row for file ${fileName}:`, error.message); return new NextResponse( @@ -113,9 +81,8 @@ export async function POST(request: NextRequest) { { status: HTTPResponses.SERVICE_UNAVAILABLE } ); } - } finally { - if (connection) connection.release(); } } + await connectionManager.closeConnection(); return new NextResponse(JSON.stringify({ message: 'Insert to SQL successful', idToRows: idToRows }), { status: HTTPResponses.OK }); } diff --git a/frontend/app/api/sqlmonitor/route.ts b/frontend/app/api/sqlmonitor/route.ts deleted file mode 100644 index 01c308a7..00000000 --- a/frontend/app/api/sqlmonitor/route.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { poolMonitor } from '@/components/processors/processormacros'; -import { HTTPResponses } from '@/config/macros'; -import { NextResponse } from 'next/server'; - -export async function GET() { - try { - const status = poolMonitor.getPoolStatus(); - return NextResponse.json({ message: 'Monitoring check successful ', status }, { status: HTTPResponses.OK }); - } catch (error: any) { - // If there's an error in getting the pool status - console.error('Error in pool monitoring:', error); - return NextResponse.json({ message: 'Monitoring check failed', error: error.message }, { status: 500 }); - } -} diff --git a/frontend/app/api/structure/[schema]/route.ts b/frontend/app/api/structure/[schema]/route.ts index 87bd3b5d..9b06e2d3 100644 --- a/frontend/app/api/structure/[schema]/route.ts +++ b/frontend/app/api/structure/[schema]/route.ts @@ -1,6 +1,5 @@ import { NextRequest } from 'next/server'; -import { getConn, runQuery } from '@/components/processors/processormacros'; -import { PoolConnection } from 'mysql2/promise'; +import ConnectionManager from '@/config/connectionmanager'; export async function GET(_request: NextRequest, { params }: { params: { schema: string } }) { const schema = params.schema; @@ -8,14 +7,14 @@ export async function GET(_request: NextRequest, { params }: { params: { schema: const query = `SELECT table_name, column_name FROM information_schema.columns WHERE table_schema = ?`; - let conn: PoolConnection | null = null; + const connectionManager = new ConnectionManager(); try { - conn = await getConn(); - return new Response(JSON.stringify(await runQuery(conn, query, [schema])), { status: 200 }); + const results = await connectionManager.executeQuery(query, [schema]); + return new Response(JSON.stringify(results), { status: 200 }); } catch (e: any) { console.error('Error:', e); throw new Error('Call failed: ', e); } finally { - if (conn) conn.release(); + await connectionManager.closeConnection(); } } diff --git a/frontend/app/api/validations/crud/route.ts b/frontend/app/api/validations/crud/route.ts index 508eceef..952d7e4b 100644 --- a/frontend/app/api/validations/crud/route.ts +++ b/frontend/app/api/validations/crud/route.ts @@ -1,76 +1,75 @@ import { NextRequest, NextResponse } from 'next/server'; import { ValidationProceduresRDS } from '@/config/sqlrdsdefinitions/validations'; -import { format, PoolConnection } from 'mysql2/promise'; -import { getConn, runQuery } from '@/components/processors/processormacros'; +import { format } from 'mysql2/promise'; import { HTTPResponses } from '@/config/macros'; import MapperFactory from '@/config/datamapper'; +import ConnectionManager from '@/config/connectionmanager'; export async function GET(_request: NextRequest) { - let conn: PoolConnection | null = null; + const connectionManager = new ConnectionManager(); try { - conn = await getConn(); const query = `SELECT * FROM catalog.validationprocedures;`; - const results = await runQuery(conn, query); + const results = await connectionManager.executeQuery(query); return new NextResponse(JSON.stringify(MapperFactory.getMapper('validationprocedures').mapData(results)), { status: HTTPResponses.OK }); } catch (error: any) { console.error('Error:', error); return NextResponse.json({}, { status: HTTPResponses.CONFLICT }); } finally { - if (conn) conn.release(); + await connectionManager.closeConnection(); } } export async function POST(request: NextRequest) { const { validationProcedure }: { validationProcedure: ValidationProceduresRDS } = await request.json(); - let conn: PoolConnection | null = null; + const connectionManager = new ConnectionManager(); try { - conn = await getConn(); delete validationProcedure['validationID']; const insertQuery = format('INSERT INTO ?? SET ?', [`catalog.validationprocedures`, validationProcedure]); - const results = await runQuery(conn, insertQuery); + const results = await connectionManager.executeQuery(insertQuery); const insertID = results.insertId; return NextResponse.json({ insertID }, { status: HTTPResponses.OK }); } catch (error: any) { console.error('Error:', error); + await connectionManager.rollbackTransaction(); return NextResponse.json({}, { status: HTTPResponses.CONFLICT }); } finally { - if (conn) conn.release(); + await connectionManager.closeConnection(); } } export async function PATCH(request: NextRequest) { const { validationProcedure }: { validationProcedure: ValidationProceduresRDS } = await request.json(); - let conn: PoolConnection | null = null; + const connectionManager = new ConnectionManager(); try { - conn = await getConn(); const updatedValidationProcedure = delete validationProcedure['validationID']; const updateQuery = format('UPDATE ?? SET ? WHERE ValidationID = ?', [ `catalog.validationprocedures`, updatedValidationProcedure, validationProcedure.validationID ]); - await runQuery(conn, updateQuery); + await connectionManager.executeQuery(updateQuery); return NextResponse.json({}, { status: HTTPResponses.OK }); } catch (error: any) { console.error('Error:', error); + await connectionManager.rollbackTransaction(); return NextResponse.json({}, { status: HTTPResponses.CONFLICT }); } finally { - if (conn) conn.release(); + await connectionManager.closeConnection(); } } export async function DELETE(request: NextRequest) { const { validationProcedure }: { validationProcedure: ValidationProceduresRDS } = await request.json(); - let conn: PoolConnection | null = null; + const connectionManager = new ConnectionManager(); try { - conn = await getConn(); const deleteQuery = format('DELETE FROM ?? WHERE ValidationID = ?', [`catalog.validationprocedures`, validationProcedure.validationID]); - await runQuery(conn, deleteQuery); + await connectionManager.executeQuery(deleteQuery); return NextResponse.json({}, { status: HTTPResponses.OK }); } catch (error: any) { console.error('Error:', error); + await connectionManager.rollbackTransaction(); return NextResponse.json({}, { status: HTTPResponses.CONFLICT }); } finally { - if (conn) conn.release(); + await connectionManager.closeConnection(); } } diff --git a/frontend/app/api/validations/procedures/[validationType]/route.ts b/frontend/app/api/validations/procedures/[validationType]/route.ts index 7606a619..512ee90b 100644 --- a/frontend/app/api/validations/procedures/[validationType]/route.ts +++ b/frontend/app/api/validations/procedures/[validationType]/route.ts @@ -2,13 +2,15 @@ import { NextRequest, NextResponse } from 'next/server'; import { runValidation } from '@/components/processors/processorhelperfunctions'; import { HTTPResponses } from '@/config/macros'; -export async function POST(request: NextRequest, { params }: { params: { validationProcedureName: string } }) { +export async function POST(request: NextRequest, { params }: { params: { validationType: string } }) { try { - const { schema, validationProcedureID, cursorQuery, p_CensusID, p_PlotID, minDBH, maxDBH, minHOM, maxHOM } = await request.json(); - console.log('data: ', schema, validationProcedureID, cursorQuery, p_CensusID, p_PlotID, minDBH, maxDBH, minHOM, maxHOM); + if (!params.validationType) throw new Error('validationProcedureName not provided'); + const body = await request.json(); + const { schema, validationProcedureID, cursorQuery, p_CensusID, p_PlotID, minDBH, maxDBH, minHOM, maxHOM } = body; + console.log('body received from request: ', body); // Execute the validation procedure using the provided inputs - const validationResponse = await runValidation(validationProcedureID, params.validationProcedureName, schema, cursorQuery, { + const validationResponse = await runValidation(validationProcedureID, params.validationType, schema, cursorQuery, { p_CensusID, p_PlotID, minDBH, diff --git a/frontend/app/api/validations/validationerrordisplay/route.ts b/frontend/app/api/validations/validationerrordisplay/route.ts index a68def84..077ecc8b 100644 --- a/frontend/app/api/validations/validationerrordisplay/route.ts +++ b/frontend/app/api/validations/validationerrordisplay/route.ts @@ -1,23 +1,24 @@ import { NextRequest, NextResponse } from 'next/server'; -import { getConn, runQuery } from '@/components/processors/processormacros'; -import { PoolConnection } from 'mysql2/promise'; import { CMError } from '@/config/macros/uploadsystemmacros'; import { HTTPResponses } from '@/config/macros'; +import ConnectionManager from '@/config/connectionmanager'; export async function GET(request: NextRequest) { - let conn: PoolConnection | null = null; + const conn = new ConnectionManager(); const schema = request.nextUrl.searchParams.get('schema'); + const plotIDParam = request.nextUrl.searchParams.get('plotIDParam'); + const censusPCNParam = request.nextUrl.searchParams.get('censusPCNParam'); if (!schema) throw new Error('No schema variable provided!'); try { - conn = await getConn(); - + await conn.beginTransaction(); // Query to fetch existing validation errors const validationErrorsQuery = ` SELECT cm.CoreMeasurementID AS CoreMeasurementID, GROUP_CONCAT(ve.ValidationID) AS ValidationErrorIDs, - GROUP_CONCAT(ve.Description) AS Descriptions + GROUP_CONCAT(ve.Description) AS Descriptions, + GROUP_CONCAT(ve.Criteria) AS Criteria FROM ${schema}.cmverrors AS cve JOIN @@ -27,13 +28,15 @@ export async function GET(request: NextRequest) { GROUP BY cm.CoreMeasurementID; `; - const validationErrorsRows = await runQuery(conn, validationErrorsQuery); + const validationErrorsRows = await conn.executeQuery(validationErrorsQuery); const parsedValidationErrors: CMError[] = validationErrorsRows.map((row: any) => ({ coreMeasurementID: row.CoreMeasurementID, validationErrorIDs: row.ValidationErrorIDs.split(',').map(Number), - descriptions: row.Descriptions.split(',') + descriptions: row.Descriptions.split(','), + criteria: row.Criteria.split(',') })); + console.log('parsedValidationErrors: ', parsedValidationErrors); return new NextResponse( JSON.stringify({ failed: parsedValidationErrors @@ -46,10 +49,11 @@ export async function GET(request: NextRequest) { } ); } catch (error: any) { + await conn.rollbackTransaction(); return new NextResponse(JSON.stringify({ error: error.message }), { status: 500 }); } finally { - if (conn) conn.release(); + await conn.closeConnection(); } } diff --git a/frontend/app/api/validations/validationlist/route.ts b/frontend/app/api/validations/validationlist/route.ts index 0e3b80bb..d77b426a 100644 --- a/frontend/app/api/validations/validationlist/route.ts +++ b/frontend/app/api/validations/validationlist/route.ts @@ -1,7 +1,6 @@ -import { getConn, runQuery } from '@/components/processors/processormacros'; import { HTTPResponses } from '@/config/macros'; -import { PoolConnection } from 'mysql2/promise'; import { NextRequest, NextResponse } from 'next/server'; +import ConnectionManager from '@/config/connectionmanager'; type ValidationProcedure = { ValidationID: number; @@ -22,16 +21,15 @@ type ValidationMessages = { }; export async function GET(request: NextRequest): Promise> { - let conn: PoolConnection | null = null; + const conn = new ConnectionManager(); const schema = request.nextUrl.searchParams.get('schema'); if (!schema) throw new Error('No schema variable provided!'); try { - conn = await getConn(); const query = `SELECT ValidationID, ProcedureName, Description, Definition FROM catalog.validationprocedures WHERE IsEnabled IS TRUE;`; - const results: ValidationProcedure[] = await runQuery(conn, query); + const results: ValidationProcedure[] = await conn.executeQuery(query); - const customQuery = `SELECT ValidationProcedureID, Name, Description, Definition FROM ${schema}.sitespecificvalidations;`; - const customResults: SiteSpecificValidations[] = await runQuery(conn, customQuery); + const customQuery = `SELECT ValidationProcedureID, Name, Description, Definition FROM ${schema}.sitespecificvalidations WHERE IsEnabled IS TRUE;`; + const customResults: SiteSpecificValidations[] = await conn.executeQuery(customQuery); const validationMessages: ValidationMessages = results.reduce((acc, { ValidationID, ProcedureName, Description, Definition }) => { acc[ProcedureName] = { id: ValidationID, description: Description, definition: Definition }; @@ -42,17 +40,17 @@ export async function GET(request: NextRequest): Promise ( - + {word1} @@ -79,7 +74,6 @@ export const quadratGridColumns: GridColDef[] = [ headerName: 'Coordinate Units', headerClassName: 'header', flex: 1, - // renderHeader: () => formatHeader('Coordinate', 'Units'), align: 'right', headerAlign: 'right', editable: true, @@ -104,7 +98,6 @@ export const quadratGridColumns: GridColDef[] = [ headerName: 'Area Unit', headerClassName: 'header', flex: 1, - // renderHeader: () => formatHeader('Area', 'Unit'), align: 'right', headerAlign: 'right', editable: true, @@ -318,9 +311,9 @@ export const StemTaxonomiesViewGridColumns: GridColDef[] = [ // note --> originally attempted to use GridValueFormatterParams, but this isn't exported by MUI X DataGrid anymore. replaced with for now. -const renderDBHCell = (params: GridRenderEditCellParams) => { +export const renderDBHCell = (params: GridRenderEditCellParams) => { const value = params.row.measuredDBH ? Number(params.row.measuredDBH).toFixed(2) : 'null'; - const units = params.row.dbhUnits || ''; + const units = params.row.dbhUnits ? (params.row.measuredDBH !== null ? params.row.dbhUnits : '') : ''; return ( @@ -330,7 +323,7 @@ const renderDBHCell = (params: GridRenderEditCellParams) => { ); }; -const renderEditDBHCell = (params: GridRenderEditCellParams) => { +export const renderEditDBHCell = (params: GridRenderEditCellParams) => { const apiRef = useGridApiRef(); const { id, row } = params; const [error, setError] = useState(false); @@ -395,12 +388,12 @@ const renderEditDBHCell = (params: GridRenderEditCellParams) => { const renderHOMCell = (params: GridRenderEditCellParams) => { const value = params.row.measuredHOM ? Number(params.row.measuredHOM).toFixed(2) : 'null'; - const units = params.row.homUnits || ''; + const units = params.row.homUnits ? (params.row.measuredHOM !== null ? params.row.homUnits : '') : ''; return ( - {value} - {units} + {value && {value}} + {units && {units}} ); }; @@ -578,7 +571,7 @@ export const MeasurementsSummaryViewGridColumns: GridColDef[] = [ editable: true }, { - field: 'stemUnits', + field: 'coordinateUnits', headerName: 'Stem Units', headerClassName: 'header', flex: 0.4, @@ -593,33 +586,12 @@ export const MeasurementsSummaryViewGridColumns: GridColDef[] = [ field: 'measuredDBH', headerName: 'DBH', headerClassName: 'header', - flex: 0.8, + flex: 0.5, align: 'right', editable: true, - // type: 'number', - // valueFormatter: (value: any) => { - // return Number(value).toFixed(2); - // } renderCell: renderDBHCell, renderEditCell: renderEditDBHCell - // valueFormatter: (params: any) => { - // const value = params.row.measuredDBH ? Number(params.row.measuredDBH).toFixed(2) : 'null'; - // const units = params.row.dbhUnits || ''; - // return `${value} ${units}`; - // } }, - // { - // field: 'dbhUnits', - // headerName: 'DBH Units', - // headerClassName: 'header', - // flex: 0.4, - // maxWidth: 65, - // renderHeader: () => formatHeader('DBH', 'Units'), - // align: 'center', - // editable: true, - // type: 'singleSelect', - // valueOptions: unitSelectionOptions - // }, { field: 'measuredHOM', headerName: 'HOM', @@ -628,42 +600,21 @@ export const MeasurementsSummaryViewGridColumns: GridColDef[] = [ align: 'right', headerAlign: 'left', editable: true, - // type: 'number', - // valueFormatter: (value: any) => { - // return Number(value).toFixed(2); - // } renderCell: renderHOMCell, renderEditCell: renderEditHOMCell - // valueFormatter: (params: any) => { - // const value = params.row.measuredDBH ? Number(params.row.measuredDBH).toFixed(2) : 'null'; - // const units = params.row.dbhUnits || ''; - // return `${value} ${units}`; - // } }, - // { - // field: 'homUnits', - // headerName: 'HOM Units', - // headerClassName: 'header', - // flex: 0.4, - // maxWidth: 65, - // renderHeader: () => formatHeader('HOM', 'Units'), - // align: 'center', - // editable: true, - // type: 'singleSelect', - // valueOptions: unitSelectionOptions - // }, { field: 'description', headerName: 'Description', headerClassName: 'header', - flex: 1, + flex: 0.6, align: 'left', editable: true }, { field: 'attributes', headerName: 'Attributes', headerClassName: 'header', flex: 1, align: 'left', editable: true } ]; -export const CensusGridColumns: GridColDef[] = [ +export const StemGridColumns: GridColDef[] = [ { field: 'id', headerName: '#', @@ -674,164 +625,69 @@ export const CensusGridColumns: GridColDef[] = [ editable: false }, { - field: 'censusID', - headerName: 'ID', - type: 'number', + field: 'stemTag', + headerName: 'Stem Tag', headerClassName: 'header', flex: 1, align: 'left', - editable: false + type: 'string', + editable: true }, { - field: 'plotCensusNumber', - headerName: 'PlotCensusNumber', - type: 'number', + field: 'localX', + headerName: 'Plot X', headerClassName: 'header', flex: 1, align: 'left', - editable: false + type: 'number', + valueFormatter: (value: any) => { + return Number(value).toFixed(2); + }, + editable: true }, { - field: 'startDate', - headerName: 'Starting', + field: 'localY', + headerName: 'Plot Y', headerClassName: 'header', flex: 1, align: 'left', - type: 'date', - editable: true, - valueFormatter: (params: any) => { - if (params) { - return new Date(params).toDateString(); - } else return 'null'; - } + type: 'number', + valueFormatter: (value: any) => { + return Number(value).toFixed(2); + }, + editable: true }, { - field: 'endDate', - headerName: 'Ending', + field: 'coordinateUnits', + headerName: 'Unit', headerClassName: 'header', - type: 'date', flex: 1, align: 'left', - editable: true, - valueFormatter: (params: any) => { - if (params) { - return new Date(params).toDateString(); - } else return 'null'; - } - }, - { - field: 'description', - headerName: 'Description', - headerClassName: 'header', - flex: 1, - type: 'string', + type: 'singleSelect', + valueOptions: unitSelectionOptions, editable: true - } -]; - -export const ValidationErrorGridColumns: GridColDef[] = [ - { - field: 'id', - headerName: '#', - headerClassName: 'header', - flex: 0.3, - align: 'right', - headerAlign: 'right', - editable: false - }, - { field: 'validationErrorID', headerName: 'ValidationErrorID', headerClassName: 'header', flex: 1, align: 'left' }, - { - field: 'validationErrorDescription', - headerName: 'ValidationErrorDescription', - headerClassName: 'header', - flex: 1, - align: 'left' - } -]; - -export const CoreMeasurementsGridColumns: GridColDef[] = [ - { - field: 'id', - headerName: 'ID', - headerClassName: 'header', - flex: 0.3, - align: 'right', - headerAlign: 'right', - editable: false - }, - { - field: 'coreMeasurementID', - headerName: '#', - headerAlign: 'left', - headerClassName: 'header', - flex: 0.25, - align: 'left' }, { - field: 'censusID', - headerName: 'Census ID', - headerAlign: 'left', + field: 'moved', + headerName: 'Moved', headerClassName: 'header', flex: 1, align: 'left', + type: 'boolean', editable: true }, { - field: 'stemID', - headerName: 'Stem ID', - headerAlign: 'left', + field: 'stemDescription', + headerName: 'StemDescription', headerClassName: 'header', flex: 1, align: 'left', + type: 'string', editable: true - }, - { - field: 'measuredDBH', - headerName: 'DBH', - headerClassName: 'header', - flex: 0.8, - align: 'right', - editable: true, - renderCell: renderDBHCell, - renderEditCell: renderEditDBHCell - }, - { - field: 'dbhUnits', - headerName: 'DBH Units', - headerClassName: 'header', - flex: 0.4, - maxWidth: 65, - renderHeader: () => formatHeader('DBH', 'Units'), - align: 'center', - editable: true, - type: 'singleSelect', - valueOptions: unitSelectionOptions - }, - { - field: 'measuredHOM', - headerName: 'HOM', - headerClassName: 'header', - flex: 0.5, - align: 'right', - headerAlign: 'left', - editable: true, - renderCell: renderHOMCell, - renderEditCell: renderEditHOMCell - }, - { - field: 'homUnits', - headerName: 'HOM Units', - headerClassName: 'header', - maxWidth: 65, - renderHeader: () => formatHeader('HOM', 'Units'), - align: 'center', - editable: true, - type: 'singleSelect', - valueOptions: unitSelectionOptions } ]; -export const SubquadratGridColumns: GridColDef[] = [ +export const SpeciesLimitsGridColumns: GridColDef[] = [ { field: 'id', headerName: '#', @@ -841,75 +697,67 @@ export const SubquadratGridColumns: GridColDef[] = [ headerAlign: 'right', editable: false }, - { field: 'ordering', headerName: 'Order', headerClassName: 'header', flex: 1, align: 'left', editable: false }, { - field: 'subquadratName', - headerName: 'Name', + field: 'speciesLimitID', + headerName: '#', headerClassName: 'header', - flex: 1, + flex: 0.3, align: 'left', - type: 'string', - editable: true + headerAlign: 'left', + editable: false }, - { field: 'quadratID', headerName: 'Quadrat', headerClassName: 'header', flex: 1, align: 'left', editable: false }, { - field: 'dimensionX', - headerName: 'X-Dimension', + field: 'speciesID', + headerName: 'SpeciesID', headerClassName: 'header', - flex: 1, + flex: 0.3, align: 'left', - type: 'number', - editable: true + headerAlign: 'left', + editable: false }, { - field: 'dimensionY', - headerName: 'Y-Dimension', - headerClassName: 'header', - flex: 1, + field: 'limitType', + headerName: 'LimitType', + renderHeader: () => formatHeader('Limit', 'Type'), + flex: 0.5, align: 'left', - type: 'number', - valueFormatter: (value: any) => { - return Number(value).toFixed(2); - }, + headerAlign: 'left', + type: 'singleSelect', + valueOptions: ['DBH', 'HOM'], editable: true }, { - field: 'qX', - headerName: 'X', - headerClassName: 'header', - flex: 1, + field: 'lowerBound', + headerName: 'LowerBound', + renderHeader: () => formatHeader('Lower', 'Limit'), + flex: 0.5, align: 'left', + headerAlign: 'left', type: 'number', - valueFormatter: (value: any) => { - return Number(value).toFixed(2); - }, editable: true }, { - field: 'qY', - headerName: 'Y', - headerClassName: 'header', - flex: 1, + field: 'upperBound', + headerName: 'UpperBound', + renderHeader: () => formatHeader('Upper', 'Limit'), + flex: 0.5, align: 'left', + headerAlign: 'left', type: 'number', - valueFormatter: (value: any) => { - return Number(value).toFixed(2); - }, editable: true }, { field: 'unit', headerName: 'Units', headerClassName: 'header', - flex: 1, + flex: 0.3, align: 'left', type: 'singleSelect', - valueOptions: unitSelectionOptions, - editable: true + valueOptions: unitSelectionOptions } ]; -export const StemGridColumns: GridColDef[] = [ +export const RolesGridColumns: GridColDef[] = [ { field: 'id', headerName: '#', @@ -920,523 +768,27 @@ export const StemGridColumns: GridColDef[] = [ editable: false }, { - field: 'stemTag', - headerName: 'Stem Tag', - headerClassName: 'header', - flex: 1, - align: 'left', - type: 'string', - editable: true - }, - { - field: 'localX', - headerName: 'Plot X', - headerClassName: 'header', - flex: 1, - align: 'left', - type: 'number', - valueFormatter: (value: any) => { - return Number(value).toFixed(2); - }, - editable: true - }, - { - field: 'localY', - headerName: 'Plot Y', - headerClassName: 'header', - flex: 1, - align: 'left', - type: 'number', - valueFormatter: (value: any) => { - return Number(value).toFixed(2); - }, - editable: true - }, - { - field: 'coordinateUnits', - headerName: 'Unit', - headerClassName: 'header', - flex: 1, - align: 'left', - type: 'singleSelect', - valueOptions: unitSelectionOptions, - editable: true - }, - { - field: 'moved', - headerName: 'Moved', + field: 'roleID', + headerName: '#', headerClassName: 'header', - flex: 1, - align: 'left', - type: 'boolean', - editable: true + flex: 0.2, + align: 'right', + headerAlign: 'right', + editable: false }, + { field: 'roleName', headerName: 'Role', headerClassName: 'header', flex: 1, align: 'left', editable: true }, { - field: 'stemDescription', - headerName: 'StemDescription', + field: 'roleDescription', + headerName: 'Description', headerClassName: 'header', flex: 1, align: 'left', - type: 'string', editable: true } ]; - -export const SpeciesInventoryGridColumns: GridColDef[] = [ - { - field: 'id', - headerName: '#', - headerClassName: 'header', - flex: 0.3, - align: 'right', - headerAlign: 'right', - editable: false - }, - { field: 'speciesInventoryID', headerName: 'SpeciesInventoryID', headerClassName: 'header', flex: 1, align: 'left' }, - { field: 'censusID', headerName: 'CensusID', headerClassName: 'header', flex: 1, align: 'left' }, - { field: 'plotID', headerName: 'PlotID', headerClassName: 'header', flex: 1, align: 'left' }, - { field: 'speciesID', headerName: 'SpeciesID', headerClassName: 'header', flex: 1, align: 'left' }, - { field: 'subSpeciesID', headerName: 'SubSpeciesID', headerClassName: 'header', flex: 1, align: 'left' } -]; - -export const SpeciesGridColumns: GridColDef[] = [ - { - field: 'id', - headerName: '#', - headerClassName: 'header', - flex: 0.3, - align: 'right', - headerAlign: 'right', - editable: false - }, - { - field: 'speciesCode', - headerName: 'SpCode', - headerClassName: 'header', - flex: 1, - align: 'left', - type: 'string', - editable: true, - maxWidth: 125 - }, - { - field: 'speciesName', - headerName: 'Species', - headerClassName: 'header', - flex: 1, - align: 'left', - type: 'string', - editable: true - }, - { - field: 'subspeciesName', - headerName: 'Subspecies', - headerClassName: 'header', - flex: 1, - align: 'left', - type: 'string', - editable: true - }, - { - field: 'idLevel', - headerName: 'IDLevel', - headerClassName: 'header', - flex: 1, - align: 'left', - type: 'string', - editable: true - }, - { - field: 'speciesAuthority', - headerName: 'SpeciesAuth', - headerClassName: 'header', - flex: 1, - align: 'left', - type: 'string', - editable: true - }, - { - field: 'subspeciesAuthority', - headerName: 'SubspeciesAuth', - headerClassName: 'header', - flex: 1, - align: 'left', - type: 'string', - editable: true - }, - { - field: 'fieldFamily', - headerName: 'FieldFamily', - headerClassName: 'header', - flex: 1, - align: 'left', - type: 'string', - editable: true - }, - { - field: 'description', - headerName: 'Description', - headerClassName: 'header', - flex: 1, - align: 'left', - type: 'string', - editable: true - }, - { - field: 'validCode', - headerName: 'Valid Code', - headerClassName: 'header', - flex: 1, - align: 'left', - type: 'string', - editable: true - } -]; - -export const SpeciesLimitsGridColumns: GridColDef[] = [ - { - field: 'id', - headerName: '#', - headerClassName: 'header', - flex: 0.3, - align: 'right', - headerAlign: 'right', - editable: false - }, - { - field: 'speciesLimitID', - headerName: '#', - headerClassName: 'header', - flex: 0.3, - align: 'left', - headerAlign: 'left', - editable: false - }, - { - field: 'speciesID', - headerName: 'SpeciesID', - headerClassName: 'header', - flex: 0.3, - align: 'left', - headerAlign: 'left', - editable: false - }, - { - field: 'limitType', - headerName: 'LimitType', - renderHeader: () => formatHeader('Limit', 'Type'), - flex: 0.5, - align: 'left', - headerAlign: 'left', - type: 'singleSelect', - valueOptions: ['DBH', 'HOM'], - editable: true - }, - { - field: 'lowerBound', - headerName: 'LowerBound', - renderHeader: () => formatHeader('Lower', 'Limit'), - flex: 0.5, - align: 'left', - headerAlign: 'left', - type: 'number', - editable: true - }, - { - field: 'upperBound', - headerName: 'UpperBound', - renderHeader: () => formatHeader('Upper', 'Limit'), - flex: 0.5, - align: 'left', - headerAlign: 'left', - type: 'number', - editable: true - }, - { - field: 'unit', - headerName: 'Units', - headerClassName: 'header', - flex: 0.3, - align: 'left', - type: 'singleSelect', - valueOptions: unitSelectionOptions - } -]; - -export const RolesGridColumns: GridColDef[] = [ - { - field: 'id', - headerName: '#', - headerClassName: 'header', - flex: 0.3, - align: 'right', - headerAlign: 'right', - editable: false - }, - { - field: 'roleID', - headerName: '#', - headerClassName: 'header', - flex: 0.2, - align: 'right', - headerAlign: 'right', - editable: false - }, - // { field: 'roleID', headerName: 'RoleID', headerClassName: 'header', flex: 1, align: 'left', editable: false }, - { field: 'roleName', headerName: 'Role', headerClassName: 'header', flex: 1, align: 'left', editable: true }, - { - field: 'roleDescription', - headerName: 'Description', - headerClassName: 'header', - flex: 1, - align: 'left', - editable: true - } -]; - -export const ReferenceGridColumns: GridColDef[] = [ - { - field: 'id', - headerName: '#', - headerClassName: 'header', - flex: 0.3, - align: 'right', - headerAlign: 'right', - editable: false - }, - { field: 'referenceID', headerName: 'ReferenceID', headerClassName: 'header', flex: 1, align: 'left' }, - { field: 'publicationTitle', headerName: 'PublicationTitle', headerClassName: 'header', flex: 1, align: 'left' }, - { field: 'fullReference', headerName: 'FullReference', headerClassName: 'header', flex: 1, align: 'left' }, - { - field: 'dateOfPublication', - headerName: 'DateOfPublication', - type: 'date', - headerClassName: 'header', - flex: 1, - align: 'left', - valueGetter: (params: any) => { - if (!params.value) return null; - return new Date(params.value); - } - } -]; - -export const PlotGridColumns: GridColDef[] = [ - { - field: 'id', - headerName: '#', - headerClassName: 'header', - flex: 0.3, - align: 'right', - headerAlign: 'right', - editable: false - }, - { field: 'plotID', headerName: 'PlotID', headerClassName: 'header', flex: 1, align: 'left', editable: false }, - { field: 'plotName', headerName: 'PlotName', headerClassName: 'header', flex: 1, align: 'left', editable: true }, - { - field: 'locationName', - headerName: 'LocationName', - headerClassName: 'header', - flex: 1, - align: 'left', - type: 'string', - editable: true - }, - { - field: 'countryName', - headerName: 'CountryName', - headerClassName: 'header', - flex: 1, - align: 'left', - type: 'string', - editable: true - }, - { - field: 'dimensionX', - headerName: 'DimX', - headerClassName: 'header', - flex: 1, - align: 'left', - type: 'number', - valueFormatter: (value: any) => { - return Number(value).toFixed(2); - }, - editable: true - }, - { - field: 'dimensionY', - headerName: 'DimY', - headerClassName: 'header', - flex: 1, - align: 'left', - type: 'number', - valueFormatter: (value: any) => { - return Number(value).toFixed(2); - }, - editable: true - }, - { - field: 'area', - headerName: 'Area', - headerClassName: 'header', - flex: 1, - align: 'left', - type: 'number', - valueFormatter: (value: any) => { - return Number(value).toFixed(2); - }, - editable: true - }, - { - field: 'globalX', - headerName: 'GlobalX', - headerClassName: 'header', - flex: 1, - align: 'left', - type: 'number', - valueFormatter: (value: any) => { - return Number(value).toFixed(2); - }, - editable: true - }, - { - field: 'globalY', - headerName: 'GlobalY', - headerClassName: 'header', - flex: 1, - align: 'left', - type: 'number', - valueFormatter: (value: any) => { - return Number(value).toFixed(2); - }, - editable: true - }, - { - field: 'globalZ', - headerName: 'GlobalZ', - headerClassName: 'header', - flex: 1, - align: 'left', - type: 'number', - valueFormatter: (value: any) => { - return Number(value).toFixed(2); - }, - editable: true - }, - { - field: 'unit', - headerName: 'Units', - headerClassName: 'header', - flex: 1, - align: 'left', - type: 'singleSelect', - valueOptions: unitSelectionOptions - }, - { - field: 'plotShape', - headerName: 'PlotShape', - headerClassName: 'header', - flex: 1, - align: 'left', - type: 'string', - editable: true - }, - { - field: 'plotDescription', - headerName: 'PlotDescription', - headerClassName: 'header', - flex: 1, - align: 'left', - type: 'string', - editable: true - } -]; - -export const GenusGridColumns: GridColDef[] = [ - { - field: 'id', - headerName: '#', - headerClassName: 'header', - flex: 0.3, - align: 'right', - headerAlign: 'right', - editable: false - }, - { field: 'genusID', headerName: 'GenusID', headerClassName: 'header', flex: 1, align: 'left', editable: false }, - { field: 'familyID', headerName: 'FamilyID', headerClassName: 'header', flex: 1, align: 'left', editable: false }, - { field: 'genus', headerName: 'GenusName', headerClassName: 'header', flex: 1, align: 'left', editable: true }, - { - field: 'referenceID', - headerName: 'ReferenceID', - headerClassName: 'header', - flex: 1, - align: 'left', - editable: false - }, - { - field: 'genusAuthority', - headerName: 'Authority', - headerClassName: 'header', - flex: 1, - align: 'left', - editable: true - } -]; - -export const FamilyGridColumns: GridColDef[] = [ - { - field: 'id', - headerName: '#', - headerClassName: 'header', - flex: 0.3, - align: 'right', - headerAlign: 'right', - editable: false - }, - { field: 'familyID', headerName: 'FamilyID', headerClassName: 'header', flex: 1, align: 'left', editable: false }, - { field: 'family', headerName: 'Family', headerClassName: 'header', flex: 1, align: 'left', editable: false }, - { - field: 'referenceID', - headerName: 'ReferenceID', - headerClassName: 'header', - flex: 1, - align: 'left', - editable: false - } -]; -export const CMVErrorGridColumns: GridColDef[] = [ - { - field: 'id', - headerName: '#', - headerClassName: 'header', - flex: 0.3, - align: 'right', - headerAlign: 'right', - editable: false - }, - { field: 'cmvErrorID', headerName: 'CMVErrorID', headerClassName: 'header', flex: 1, align: 'left' }, - { field: 'coreMeasurementID', headerName: 'CoreMeasurementID', headerClassName: 'header', flex: 1, align: 'left' }, - { field: 'validationErrorID', headerName: 'ValidationErrorID', headerClassName: 'header', flex: 1, align: 'left' } -]; - -export const CMAttributeGridColumns: GridColDef[] = [ - { - field: 'id', - headerName: '#', - headerClassName: 'header', - flex: 0.3, - align: 'right', - headerAlign: 'right', - editable: false - }, - { field: 'cmaID', headerName: 'CMAID', headerClassName: 'header', flex: 1, align: 'left' }, - { field: 'coreMeasurementID', headerName: 'CoreMeasurementID', headerClassName: 'header', flex: 1, align: 'left' }, - { field: 'code', headerName: 'Code', headerClassName: 'header', flex: 1, align: 'left' } -]; - -// Combine the column definitions -const combineColumns = (primary: GridColDef[], secondary: GridColDef[]): GridColDef[] => { - const combined = [...primary]; +// Combine the column definitions +const combineColumns = (primary: GridColDef[], secondary: GridColDef[]): GridColDef[] => { + const combined = [...primary]; secondary.forEach(secondaryColumn => { const primaryColumnIndex = primary.findIndex(primaryColumn => primaryColumn.field === secondaryColumn.field); @@ -1476,177 +828,4 @@ export const ViewFullTableGridColumns = rawColumns.map(column => { return column; }); -export const ValidationProceduresGridColumns: GridColDef[] = [ - { field: 'id', headerName: 'ID', headerClassName: 'header' }, - { field: 'validationID', headerName: '#', headerClassName: 'header' }, - { - field: 'procedureName', - headerName: 'Procedure', - headerClassName: 'header', - type: 'string', - editable: true, - flex: 1, - renderCell: (params: GridRenderEditCellParams) => { - const value = params.row.procedureName.replace(/(DBH|HOM)([A-Z])/g, '$1 $2').replace(/([a-z])([A-Z])/g, '$1 $2'); - return {value}; - } - }, - { - field: 'description', - headerName: 'Description', - headerClassName: 'header', - type: 'string', - editable: true, - flex: 1, - renderCell: (params: GridRenderEditCellParams) => { - return {params.row.description}; - } - }, - { - field: 'definition', - headerName: 'SQL Implementation', - headerClassName: 'header', - type: 'string', - editable: true, - flex: 1, - renderCell: (params: GridRenderEditCellParams) => { - const { data: session } = useSession(); - let isEditing = false; - if (typeof params.id === 'string') { - isEditing = params.rowModesModel[parseInt(params.id)]?.mode === 'edit'; - } - const isAdmin = session?.user?.userStatus === 'db admin' || session?.user?.userStatus === 'global'; - - if (isEditing && isAdmin) { - return ( - { - // Update the grid row with the new value from CodeMirror - params.api.updateRows([{ ...params.row, definition: value }]); - }} - /> - ); - } - - return ( - - - - - - - {params.row.description} - - - {params.row.definition} - - - - ); - } - }, - { - field: 'createdAt', - headerName: 'Created At', - renderHeader: () => formatHeader('Created', 'At'), - type: 'date', - headerClassName: 'header', - headerAlign: 'center', - valueGetter: (params: any) => { - if (!params || !params.value) return null; - return new Date(params.value); - }, - editable: true, - flex: 0.4 - }, - { - field: 'updatedAt', - headerName: 'Updated At', - renderHeader: () => formatHeader('Updated', 'At'), - type: 'date', - headerClassName: 'header', - headerAlign: 'center', - valueGetter: (params: any) => { - if (!params || !params.value) return null; - return new Date(params.value); - }, - editable: true, - flex: 0.4 - }, - { field: 'isEnabled', headerName: 'Active?', headerClassName: 'header', type: 'boolean', editable: true, flex: 0.2 } -]; - -export const SiteSpecificValidationsGridColumns: GridColDef[] = [ - { field: 'id', headerName: 'ID', headerClassName: 'header' }, - { field: 'validationProcedureID', headerName: '#', headerClassName: 'header' }, - { - field: 'name', - headerName: 'Procedure', - headerClassName: 'header', - type: 'string', - editable: true, - flex: 1, - renderCell: (params: GridRenderEditCellParams) => { - const value = params.row.procedureName.replace(/(DBH|HOM)([A-Z])/g, '$1 $2').replace(/([a-z])([A-Z])/g, '$1 $2'); - return {value}; - } - }, - { - field: 'description', - headerName: 'Description', - headerClassName: 'header', - type: 'string', - editable: true, - flex: 1, - renderCell: (params: GridRenderEditCellParams) => { - return {params.row.description}; - } - }, - { - field: 'definition', - headerName: 'SQL Implementation', - headerClassName: 'header', - type: 'string', - editable: true, - flex: 1, - renderCell: (params: GridRenderEditCellParams) => { - return ( - - - - - - - {params.row.description} - - - {params.row.description} - - - - ); - } - }, - { field: 'isEnabled', headerName: 'Active?', headerClassName: 'header', type: 'boolean', editable: true, flex: 0.2 } -]; +// FORM GRID COLUMNS diff --git a/frontend/components/client/formcolumns.tsx b/frontend/components/client/formcolumns.tsx new file mode 100644 index 00000000..254beb53 --- /dev/null +++ b/frontend/components/client/formcolumns.tsx @@ -0,0 +1,734 @@ +'use client'; + +import { GridColDef, GridRenderEditCellParams, useGridApiContext } from '@mui/x-data-grid'; +import { areaSelectionOptions, unitSelectionOptions } from '@/config/macros'; +import { formatHeader } from '@/components/client/datagridcolumns'; +import moment from 'moment/moment'; +import { Box, Input, Tooltip } from '@mui/joy'; +import { DatePicker } from '@mui/x-date-pickers'; +import React, { useEffect, useRef, useState } from 'react'; +import useEnhancedEffect from '@mui/utils/useEnhancedEffect'; +import { AttributeStatusOptions } from '@/config/sqlrdsdefinitions/core'; +import { styled } from '@mui/joy/styles'; +import { CheckCircleOutlined } from '@mui/icons-material'; + +export const renderDatePicker = (params: GridRenderEditCellParams) => { + const convertedValue = params.row.date ? moment(params.row.date, 'YYYY-MM-DD') : null; + if (!convertedValue) return <>; + + return ( + + + + ); +}; + +export const renderEditDatePicker = (params: GridRenderEditCellParams) => { + const apiRef = useGridApiContext(); + const { id, row } = params; + + return ( + + { + apiRef.current.setEditCellValue({ id, field: 'date', value: newValue ? newValue.format('YYYY-MM-DD') : null }); + }} + /> + + ); +}; + +const getClosestAreaUnit = (input: string): string | null => { + const normalizedInput = input.trim().toLowerCase(); + + // Define threshold for acceptable "closeness" (tune this value) + const threshold = 2; + + let closestUnit: string | null = null; + let minDistance = Infinity; + + for (const option of areaSelectionOptions) { + const distance = levenshteinDistance(normalizedInput, option); + if (distance < minDistance && distance <= threshold) { + minDistance = distance; + closestUnit = option; + } + } + + // Return the closest match if within the acceptable threshold, otherwise return null + return closestUnit; +}; + +export const EditUnitsCell = (params: GridRenderEditCellParams & { fieldName: string; isArea: boolean }) => { + const apiRef = useGridApiContext(); + const { id, fieldName, hasFocus, isArea } = params; + const [value, setValue] = useState(params.row[fieldName]); + const [error, setError] = useState(false); + const ref = useRef(null); + + useEnhancedEffect(() => { + if (hasFocus && ref.current) { + const input = ref.current.querySelector(`input[value="${value}"]`); + input?.focus(); + } + }, [hasFocus, value]); + + useEffect(() => { + if (!(apiRef.current.getCellMode(id, fieldName) === 'edit')) { + apiRef.current.startCellEditMode({ id, field: fieldName }); + } + }, [apiRef, id, fieldName]); + + useEffect(() => { + setError(!(isArea ? getClosestAreaUnit(value) : getClosestUnit(value))); + }, [value]); + + const handleCommit = () => { + const isValid = isArea ? getClosestAreaUnit(value) : getClosestUnit(value); + + if (!isValid) { + apiRef.current.setEditCellValue({ + id, + field: fieldName, + value: '' + }); + return; + } + + apiRef.current.stopCellEditMode({ id, field: fieldName }); + }; + + return ( + + setValue(e.target.value)} + onBlur={() => { + apiRef.current.setEditCellValue({ + id, + field: fieldName, + value: (isArea ? getClosestAreaUnit(value) : getClosestUnit(value)) || value + }); + handleCommit(); + }} + onKeyDown={e => { + if (e.key === 'Enter') { + apiRef.current.setEditCellValue({ + id, + field: fieldName, + value: (isArea ? getClosestAreaUnit(value) : getClosestUnit(value)) || value + }); + handleCommit(); + } + }} + error={error} + /> + + ); +}; + +const getClosestUnit = (input: string): string | null => { + const normalizedInput = input.trim().toLowerCase(); + + // Define threshold for acceptable "closeness" (tune this value) + const threshold = 2; + + let closestUnit: string | null = null; + let minDistance = Infinity; + + for (const option of unitSelectionOptions) { + const distance = levenshteinDistance(normalizedInput, option); + if (distance < minDistance && distance <= threshold) { + minDistance = distance; + closestUnit = option; + } + } + + // Return the closest match if within the acceptable threshold, otherwise return null + return closestUnit; +}; + +function levenshteinDistance(a: string, b: string): number { + const matrix = Array.from({ length: a.length + 1 }, () => Array(b.length + 1).fill(0)); + + for (let i = 0; i <= a.length; i++) matrix[i][0] = i; + for (let j = 0; j <= b.length; j++) matrix[0][j] = j; + + for (let i = 1; i <= a.length; i++) { + for (let j = 1; j <= b.length; j++) { + const cost = a[i - 1] === b[j - 1] ? 0 : 1; + matrix[i][j] = Math.min( + matrix[i - 1][j] + 1, // Deletion + matrix[i][j - 1] + 1, // Insertion + matrix[i - 1][j - 1] + cost // Substitution + ); + } + } + return matrix[a.length][b.length]; +} + +function normalizeString(str: string): string { + return str.replace(/[\s-]+/g, '').toLowerCase(); +} + +const getClosestStatus = (input: string): string | null => { + const normalizedInput = normalizeString(input); + + // Define threshold for acceptable "closeness" (tune this value) + const threshold = 2; + + let closestStatus: string | null = null; + let minDistance = Infinity; + + for (const option of AttributeStatusOptions) { + const normalizedOption = normalizeString(option); + const distance = levenshteinDistance(normalizedInput, normalizedOption); + if (distance < minDistance && distance <= threshold) { + minDistance = distance; + closestStatus = option; // Return the original option, not the normalized one + } + } + + // Return the closest match if within the acceptable threshold, otherwise return null + return closestStatus; +}; + +const StyledInput = styled('input')({ + border: 'none', + minWidth: 0, + outline: 0, + padding: 0, + paddingTop: '1em', + flex: 1, + color: 'inherit', + backgroundColor: 'transparent', + fontFamily: 'inherit', + fontSize: 'inherit', + fontStyle: 'inherit', + fontWeight: 'inherit', + lineHeight: 'inherit', + textOverflow: 'ellipsis', + '&::placeholder': { + opacity: 0, + transition: '0.1s ease-out' + }, + '&:focus::placeholder': { + opacity: 1 + }, + '&:focus ~ label, &:not(:placeholder-shown) ~ label, &:-webkit-autofill ~ label': { + top: '0.5rem', + fontSize: '0.75rem' + }, + '&:focus ~ label': { + color: 'var(--Input-focusedHighlight)' + }, + '&:-webkit-autofill': { + alignSelf: 'stretch' + }, + '&:-webkit-autofill:not(* + &)': { + marginInlineStart: 'calc(-1 * var(--Input-paddingInline))', + paddingInlineStart: 'var(--Input-paddingInline)', + borderTopLeftRadius: 'calc(var(--Input-radius) - var(--variant-borderWidth, 0px))', + borderBottomLeftRadius: 'calc(var(--Input-radius) - var(--variant-borderWidth, 0px))' + } +}); + +const StyledLabel = styled('label')(({ theme }) => ({ + position: 'absolute', + lineHeight: 1, + top: 'calc((var(--Input-minHeight) - 1em) / 2)', + color: theme.vars.palette.text.tertiary, + fontWeight: theme.vars.fontWeight.md, + transition: 'all 150ms cubic-bezier(0.4, 0, 0.2, 1)' +})); + +const InnerInput = React.forwardRef< + HTMLInputElement, + React.JSX.IntrinsicElements['input'] & { + error?: boolean; + noInput?: boolean; + } +>(function InnerInput(props, ref) { + const { error, noInput, ...rest } = props; + const id = React.useId(); + + return ( + + + {noInput ? AttributeStatusOptions.join(', ') : error ? 'Invalid status' : 'Accepted!'} + + ); +}); + +const EditStatusCell = (params: GridRenderEditCellParams) => { + const apiRef = useGridApiContext(); + const { id, hasFocus } = params; + const [value, setValue] = React.useState(params.row['status']); + const [error, setError] = React.useState(false); + const ref = React.useRef(null); + + useEnhancedEffect(() => { + if (hasFocus && ref.current) { + const input = ref.current.querySelector(`input[value="${value}"]`); + input?.focus(); + } + }, [hasFocus, value]); + + React.useEffect(() => { + if (!(apiRef.current.getCellMode(id, 'status') === 'edit')) { + apiRef.current.startCellEditMode({ id, field: 'status' }); + } + }, [apiRef, id]); + + React.useEffect(() => { + setError(!getClosestStatus(value) && value !== ''); + }, [value]); + + const handleCommit = () => { + const correctedValue = getClosestStatus(value); + + console.log('handle commit: corrected value: ', correctedValue); + + apiRef.current.setEditCellValue({ + id, + field: 'status', + value: value + }); + + apiRef.current.stopCellEditMode({ id, field: 'status' }); + }; + + return ( + } + slots={{ input: InnerInput }} + slotProps={{ + input: { + placeholder: 'Enter status...', + type: 'text', + error, + noInput: value === '' + } + }} + sx={{ '--Input-minHeight': '56px', '--Input-radius': '6px' }} + onChange={e => setValue(e.target.value)} + onBlur={handleCommit} + onKeyDown={e => { + if (e.key === 'Enter' || e.key === 'Tab') { + console.log('on key down: enter || tab'); + handleCommit(); + } + }} + error={error} + /> + ); +}; + +export const AttributesFormGridColumns: GridColDef[] = [ + { + field: 'id', + headerName: '#', + headerClassName: 'header', + flex: 0.3, + headerAlign: 'right', + editable: false + }, + { + field: 'code', + headerName: 'Code', + headerClassName: 'header', + flex: 1, + editable: true + }, + { + field: 'description', + headerName: 'Description', + headerClassName: 'header', + flex: 1, + editable: true + }, + { + field: 'status', + headerName: 'Status', + headerClassName: 'header', + flex: 1, + editable: true + // This is temporarily being suspended -- it's a nice to have, not a need to have + // renderEditCell: params => + } +]; + +export const PersonnelFormGridColumns: GridColDef[] = [ + { + field: 'id', + headerName: '#', + headerClassName: 'header', + flex: 0.3, + align: 'right', + headerAlign: 'right', + editable: false + }, + { + field: 'firstname', + headerName: 'First Name', + headerClassName: 'header', + flex: 1, + align: 'left', + editable: true + }, + { + field: 'lastname', + headerName: 'Last Name', + headerClassName: 'header', + flex: 1, + align: 'left', + editable: true + }, + { + field: 'role', + headerName: 'Role', + headerClassName: 'header', + flex: 1, + align: 'left', + editable: true + }, + { + field: 'roledescription', + headerName: 'Role Description', + headerClassName: 'header', + flex: 1, + align: 'left', + editable: true + } +]; + +export const SpeciesFormGridColumns: GridColDef[] = [ + { + field: 'id', + headerName: '#', + headerClassName: 'header', + flex: 0.3, + align: 'right', + headerAlign: 'right', + editable: false + }, + { + field: 'spcode', + headerName: 'Species Code', + headerClassName: 'header', + flex: 1, + align: 'left', + editable: true + }, + { + field: 'family', + headerName: 'Family', + headerClassName: 'header', + flex: 1, + align: 'left', + editable: true + }, + { + field: 'genus', + headerName: 'Genus', + headerClassName: 'header', + flex: 1, + align: 'left', + editable: true + }, + { + field: 'species', + headerName: 'Species', + headerClassName: 'header', + flex: 1, + align: 'left', + editable: true + }, + { + field: 'subspecies', + headerName: 'Subspecies', + headerClassName: 'header', + flex: 1, + align: 'left', + editable: true + }, + { + field: 'idlevel', + headerName: 'ID Level', + headerClassName: 'header', + flex: 1, + align: 'left', + editable: true + }, + { + field: 'authority', + headerName: 'Authority', + headerClassName: 'header', + flex: 1, + align: 'left', + editable: true + }, + { + field: 'subspeciesauthority', + headerName: 'Subspecies Authority', + headerClassName: 'header', + flex: 1, + align: 'left', + editable: true + } +]; + +export const QuadratsFormGridColumns: GridColDef[] = [ + { + field: 'id', + headerName: '#', + headerClassName: 'header', + flex: 0.3, + align: 'right', + headerAlign: 'right', + editable: false + }, + { + field: 'quadrat', + headerName: 'Quadrat Name', + headerClassName: 'header', + flex: 1, + align: 'left', + editable: true + }, + { + field: 'startx', + headerName: 'StartX', + headerClassName: 'header', + flex: 1, + align: 'left', + type: 'number', + editable: true + }, + { + field: 'starty', + headerName: 'StartY', + headerClassName: 'header', + flex: 1, + align: 'left', + type: 'number', + editable: true + }, + { + field: 'coordinateunit', + headerName: 'Coordinate Units', + headerClassName: 'header', + align: 'left', + editable: true, + renderEditCell: params => + }, + { + field: 'dimx', + headerName: 'Dimension X', + headerClassName: 'header', + flex: 1, + align: 'left', + type: 'number', + editable: true + }, + { + field: 'dimy', + headerName: 'Dimension Y', + headerClassName: 'header', + flex: 1, + align: 'left', + type: 'number', + editable: true + }, + { + field: 'dimensionunit', + headerName: 'Dimension Units', + headerClassName: 'header', + flex: 0.3, + align: 'left', + editable: true, + renderEditCell: params => + }, + { + field: 'area', + headerName: 'Area', + headerClassName: 'header', + flex: 1, + align: 'left', + type: 'number', + editable: true + }, + { + field: 'areaunit', + headerName: 'Area Units', + headerClassName: 'header', + flex: 0.3, + align: 'left', + editable: true, + renderEditCell: params => + }, + { + field: 'quadratshape', + headerName: 'Quadrat Shape', + headerClassName: 'header', + flex: 1, + align: 'left', + editable: true + } +]; +/** + * [FormType.measurements]: [ + * { label: 'tag' }, + * { label: 'stemtag' }, + * { label: 'spcode' }, + * { label: 'quadrat' }, + * { label: 'lx' }, + * { label: 'ly' }, + * { label: 'coordinateunit' }, + * { label: 'dbh' }, + * { label: 'dbhunit' }, + * { label: 'hom' }, + * { label: 'homunit' }, + * { label: 'date' }, + * { label: 'codes' } + * ], + */ +export const MeasurementsFormGridColumns: GridColDef[] = [ + { + field: 'id', + headerName: '#', + headerClassName: 'header', + flex: 0.3, + align: 'right', + headerAlign: 'right', + editable: false + }, + { + field: 'tag', + headerName: 'Tree Tag', + headerClassName: 'header', + renderHeader: () => formatHeader('Tree', 'Tag'), + flex: 0.75, + align: 'center', + editable: true + }, + { + field: 'stemtag', + headerName: 'Stem Tag', + headerClassName: 'header', + renderHeader: () => formatHeader('Stem', 'Tag'), + flex: 0.75, + align: 'center', + editable: true + }, + { + field: 'spcode', + headerName: 'Species Code', + headerClassName: 'header', + renderHeader: () => formatHeader('Species', 'Code'), + flex: 0.75, + align: 'center', + editable: true + }, + { + field: 'quadrat', + headerName: 'Quadrat Name', + headerClassName: 'header', + renderHeader: () => formatHeader('Quadrat', 'Name'), + flex: 0.75, + align: 'center', + editable: true + }, + { + field: 'lx', + headerName: 'X', + headerClassName: 'header', + flex: 0.3, + align: 'center', + type: 'number', + editable: true + }, + { + field: 'ly', + headerName: 'Y', + headerClassName: 'header', + flex: 0.3, + align: 'center', + type: 'number', + editable: true + }, + { + field: 'coordinateunit', + headerName: '<= Units', + headerClassName: 'header', + // renderHeader: () => formatHeader('Coordinate', 'Units'), + flex: 0.5, + align: 'center', + editable: true, + renderEditCell: params => + }, + { + field: 'dbh', + headerName: 'DBH', + headerClassName: 'header', + flex: 0.75, + align: 'center', + type: 'number', + editable: true + }, + { + field: 'dbhunit', + headerName: '<= Units', + headerClassName: 'header', + // renderHeader: () => formatHeader('DBH', 'Units'), + flex: 0.5, + align: 'center', + editable: true, + renderEditCell: params => + }, + { + field: 'hom', + headerName: 'HOM', + headerClassName: 'header', + flex: 0.75, + align: 'center', + type: 'number', + editable: true + }, + { + field: 'homunit', + headerName: '<= Units', + headerClassName: 'header', + // renderHeader: () => formatHeader('HOM', 'Units'), + flex: 0.5, + align: 'center', + editable: true, + renderEditCell: params => + } +]; diff --git a/frontend/components/client/githubfeedbackmodal.tsx b/frontend/components/client/githubfeedbackmodal.tsx index 19d7dd6d..57d3751f 100644 --- a/frontend/components/client/githubfeedbackmodal.tsx +++ b/frontend/components/client/githubfeedbackmodal.tsx @@ -12,7 +12,6 @@ import { Divider, FormControl, FormLabel, - Grid, Input, LinearProgress, List, @@ -36,6 +35,7 @@ import { usePathname } from 'next/navigation'; import { useSession } from 'next-auth/react'; import ReactMarkdown from 'react-markdown'; import remarkGfm from 'remark-gfm'; +import Grid from '@mui/material/Grid2'; // this has been shelved -- it's a little too complicated for a first iteration. // saving it for a later version. @@ -185,7 +185,7 @@ ${pathname} - + {currentSite ? ( @@ -197,7 +197,7 @@ ${pathname} No site selected. )} - + {currentPlot ? ( Selected Plot: {currentPlot.plotName} @@ -207,7 +207,7 @@ ${pathname} No plot selected. )} - + {currentCensus ? ( Selected Census: {currentCensus.plotCensusNumber} diff --git a/frontend/components/client/postvalidationrow.tsx b/frontend/components/client/postvalidationrow.tsx new file mode 100644 index 00000000..4cf11919 --- /dev/null +++ b/frontend/components/client/postvalidationrow.tsx @@ -0,0 +1,218 @@ +'use client'; +import React from 'react'; +import { Box, Collapse, TableCell, TableRow, Typography } from '@mui/material'; +import ExpandMoreIcon from '@mui/icons-material/ExpandMore'; +import ExpandLessIcon from '@mui/icons-material/ExpandLess'; +import { PostValidationQueriesRDS } from '@/config/sqlrdsdefinitions/validations'; +import { Checkbox, IconButton, Textarea, Tooltip } from '@mui/joy'; +import { Done } from '@mui/icons-material'; +import dynamic from 'next/dynamic'; +import moment from 'moment/moment'; +import { darken } from '@mui/system'; + +interface PostValidationRowProps { + postValidation: PostValidationQueriesRDS; + selectedResults: PostValidationQueriesRDS[]; + expanded: boolean; + isDarkMode: boolean; + expandedQuery: number | null; + replacements: { schema: string | undefined; currentPlotID: number | undefined; currentCensusID: number | undefined }; + handleExpandClick: (queryID: number) => void; + handleExpandResultsClick: (queryID: number) => void; + handleSelectResult: (postValidation: PostValidationQueriesRDS) => void; +} + +const Editor = dynamic(() => import('@monaco-editor/react'), { ssr: false }); + +const PostValidationRow: React.FC = ({ + expandedQuery, + replacements, + postValidation, + expanded, + isDarkMode, + handleExpandClick, + handleExpandResultsClick, + handleSelectResult, + selectedResults +}) => { + const formattedResults = JSON.stringify(JSON.parse(postValidation.lastRunResult ?? '{}'), null, 2); + + const successColor = !isDarkMode ? 'rgba(54, 163, 46, 0.3)' : darken('rgba(54,163,46,0.6)', 0.7); + const failureColor = !isDarkMode ? 'rgba(255, 0, 0, 0.3)' : darken('rgba(255,0,0,0.6)', 0.7); + + return ( + <> + + + handleExpandResultsClick(postValidation.queryID!)} + > + {expanded ? : } + + + handleSelectResult(postValidation)} style={{ cursor: 'pointer', padding: '0', textAlign: 'center' }}> + + } + label={''} + checked={selectedResults.includes(postValidation)} + slotProps={{ + root: ({ checked, focusVisible }) => ({ + sx: !checked + ? { + '& svg': { opacity: focusVisible ? 1 : 0 }, + '&:hover svg': { + opacity: 1 + } + } + : undefined + }) + }} + onChange={e => e.stopPropagation()} + /> + + + + {postValidation.queryName} + + + + {expandedQuery === postValidation.queryID ? ( + + String(replacements[p1 as keyof typeof replacements] ?? '') + )} + options={{ + readOnly: true, + minimap: { enabled: false }, + scrollBeyondLastLine: false, + wordWrap: 'off', + lineNumbers: 'off' + }} + theme={isDarkMode ? 'vs-dark' : 'light'} + /> + ) : ( +