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/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 1037c43f..630bf1ca 100644 --- a/frontend/app/(hub)/dashboard/page.tsx +++ b/frontend/app/(hub)/dashboard/page.tsx @@ -53,7 +53,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; @@ -161,14 +160,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/api/auth/[[...nextauth]]/route.ts b/frontend/app/api/auth/[[...nextauth]]/route.ts index b19e1a03..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!, @@ -28,11 +28,11 @@ const handler = NextAuth({ return false; // Email is not a valid string, abort sign-in } if (userEmail) { - let conn, emailVerified, userStatus; + const connectionManager = new ConnectionManager(); + let emailVerified, userStatus, userID; try { - conn = await getConn(); - const query = `SELECT UserStatus FROM catalog.users WHERE Email = '${userEmail}' LIMIT 1`; - const results = await runQuery(conn, query); + 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; @@ -41,17 +41,21 @@ const handler = NextAuth({ return false; } userStatus = results[0].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; diff --git a/frontend/app/api/bulkcrud/[dataType]/[[...slugs]]/route.ts b/frontend/app/api/bulkcrud/[dataType]/[[...slugs]]/route.ts index 9651d2f5..f8936e95 100644 --- a/frontend/app/api/bulkcrud/[dataType]/[[...slugs]]/route.ts +++ b/frontend/app/api/bulkcrud/[dataType]/[[...slugs]]/route.ts @@ -1,10 +1,9 @@ // 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 { PoolConnection } from 'mysql2/promise'; -import { getConn, InsertUpdateProcessingProps } from '@/components/processors/processormacros'; import { insertOrUpdate } from '@/components/processors/processorhelperfunctions'; -import { HTTPResponses } from '@/config/macros'; +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; @@ -20,15 +19,15 @@ export async function POST(request: NextRequest, { params }: { params: { dataTyp return new NextResponse('No rows provided', { status: 400 }); } console.log('rows produced: ', rows); - let conn: PoolConnection | null = null; + const connectionManager = new ConnectionManager(); try { - conn = await getConn(); for (const rowID in rows) { + await connectionManager.beginTransaction(); const rowData = rows[rowID]; console.log('rowData obtained: ', rowData); const props: InsertUpdateProcessingProps = { schema, - connection: conn, + connectionManager: connectionManager, formType: dataType, rowData, plotID, @@ -38,8 +37,11 @@ export async function POST(request: NextRequest, { params }: { params: { dataTyp }; 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}`, @@ -48,7 +50,6 @@ export async function POST(request: NextRequest, { params }: { params: { dataTyp { status: HTTPResponses.INTERNAL_SERVER_ERROR } ); } finally { - if (conn) conn.release(); + await connectionManager.closeConnection(); } - return new NextResponse(JSON.stringify({ message: 'Insert to SQL successful' }), { status: HTTPResponses.OK }); } diff --git a/frontend/app/api/catalog/[firstName]/[lastName]/route.ts b/frontend/app/api/catalog/[firstName]/[lastName]/route.ts index 220a2898..773769d1 100644 --- a/frontend/app/api/catalog/[firstName]/[lastName]/route.ts +++ b/frontend/app/api/catalog/[firstName]/[lastName]/route.ts @@ -1,18 +1,16 @@ import { NextRequest, NextResponse } from 'next/server'; -import { PoolConnection } from 'mysql2/promise'; -import { getConn, runQuery } from '@/components/processors/processormacros'; 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!'); - let conn: PoolConnection | null = null; + const connectionManager = new ConnectionManager(); try { - conn = await getConn(); const query = `SELECT UserID FROM catalog.users WHERE FirstName = ? AND LastName = ?;`; - const results = await runQuery(conn, query, [firstName, lastName]); + const results = await connectionManager.executeQuery(query, [firstName, lastName]); if (results.length === 0) { throw new Error('User not found'); } @@ -21,6 +19,6 @@ export async function GET(_request: NextRequest, { params }: { params: { firstNa console.error('Error in GET request:', e.message); return new NextResponse(JSON.stringify({ error: e.message }), { status: HTTPResponses.INTERNAL_SERVER_ERROR }); } finally { - if (conn) conn.release(); + 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 963accc9..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,8 +44,7 @@ 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 @@ -61,26 +55,12 @@ export async function GET(_request: NextRequest, { params }: { params: { dataTyp 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 runQuery(connection, pvQuery); - if (connection) connection.release(); + const pvResults = await connection.executeQuery(pvQuery); if (pvResults.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 'quadratpersonnel': // Validation for quadrats table const quadratsQuery = `SELECT 1 @@ -90,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 @@ -99,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 @@ -113,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); @@ -121,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 bbaf8c6f..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') { @@ -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 0e37856f..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': @@ -99,36 +89,6 @@ export async function GET( WHERE c.PlotID = ? AND c.PlotCensusNumber = ? LIMIT ?, ?;`; queryParams.push(plotID, plotCensusNumber, page * pageSize, pageSize); break; - case 'measurementssummary': - case 'measurementssummary_staging': - case 'measurementssummaryview': - case 'viewfulltable': - case 'viewfulltableview': - 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 = ? - ORDER BY vft.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 * @@ -136,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}`); } @@ -179,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(); } } @@ -229,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; @@ -249,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 @@ -270,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(); } } @@ -297,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 @@ -338,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(); } } @@ -362,15 +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(); 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 index 0f3b83d6..0a238aca 100644 --- a/frontend/app/api/formdownload/[dataType]/[[...slugs]]/route.ts +++ b/frontend/app/api/formdownload/[dataType]/[[...slugs]]/route.ts @@ -1,9 +1,8 @@ import { NextRequest, NextResponse } from 'next/server'; -import { PoolConnection } from 'mysql2/promise'; -import { getConn, runQuery } from '@/components/processors/processormacros'; 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; @@ -14,17 +13,16 @@ export async function GET(_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(); let query: string = ''; let results: any[] = []; let mappedResults: any[] = []; let formMappedResults: any[] = []; try { - conn = await getConn(); switch (dataType) { case 'attributes': query = `SELECT * FROM ${schema}.attributes`; - results = await runQuery(conn, query); + results = await connectionManager.executeQuery(query); mappedResults = MapperFactory.getMapper('attributes').mapData(results); formMappedResults = mappedResults.map((row: AttributesRDS) => ({ code: row.code, @@ -38,7 +36,7 @@ export async function GET(_request: NextRequest, { params }: { params: { dataTyp 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 runQuery(conn, query, [plotID, censusID]); + results = await connectionManager.executeQuery(query, [plotID, censusID]); formMappedResults = results.map((row: any) => ({ firstname: row.FirstName, lastname: row.LastName, @@ -58,7 +56,7 @@ export async function GET(_request: NextRequest, { params }: { params: { dataTyp 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 runQuery(conn, query, [plotID, censusID]); + results = await connectionManager.executeQuery(query, [plotID, censusID]); formMappedResults = results.map((row: any) => ({ spcode: row.SpeciesCode, family: row.Family, @@ -74,7 +72,7 @@ export async function GET(_request: NextRequest, { params }: { params: { dataTyp query = `SELECT * FROM ${schema}.quadrats q JOIN ${schema}.censusquadrat cq ON cq.QuadratID = q.QuadratID WHERE q.PlotID = ? AND cq.CensusID = ?`; - results = await runQuery(conn, query, [plotID, censusID]); + results = await connectionManager.executeQuery(query, [plotID, censusID]); formMappedResults = results.map((row: any) => ({ quadrat: row.QuadratName, startx: row.StartX, @@ -102,7 +100,7 @@ export async function GET(_request: NextRequest, { params }: { params: { dataTyp 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 runQuery(conn, query, [plotID, censusID]); + results = await connectionManager.executeQuery(query, [plotID, censusID]); formMappedResults = results.map((row: any) => ({ tag: row.TreeTag, stemtag: row.StemTag, @@ -125,6 +123,6 @@ export async function GET(_request: NextRequest, { params }: { params: { dataTyp } catch (e: any) { throw new Error(e); } finally { - if (conn) conn.release(); + 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 e1ee5424..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 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 e40acc17..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,7 +1,7 @@ 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; @@ -13,12 +13,14 @@ export async function GET(_request: NextRequest, { params }: { params: { schema: return new NextResponse('Missing parameters', { status: HTTPResponses.INVALID_REQUEST }); } - const conn = await getConn(); + const connectionManager = new ConnectionManager(); try { const query = `SELECT QueryDefinition FROM ${schema}.postvalidationqueries WHERE QueryID = ${queryID}`; - const results = await runQuery(conn, query); + const results = await connectionManager.executeQuery(query); - if (results.length === 0) return new NextResponse('Query not found', { status: HTTPResponses.NOT_FOUND }); + if (results.length === 0) { + return new NextResponse('Query not found', { status: HTTPResponses.NOT_FOUND }); + } const replacements = { schema: schema, @@ -26,8 +28,8 @@ export async function GET(_request: NextRequest, { params }: { params: { schema: 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); + await connectionManager.beginTransaction(); + const queryResults = await connectionManager.executeQuery(formattedQuery); if (queryResults.length === 0) throw new Error('failure'); @@ -36,20 +38,20 @@ export async function GET(_request: NextRequest, { params }: { params: { schema: const successUpdate = `UPDATE ${schema}.postvalidationqueries SET LastRunAt = ?, LastRunResult = ?, LastRunStatus = 'success' WHERE QueryID = ${queryID}`; - await runQuery(conn, successUpdate, [currentTime, successResults]); - + 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 runQuery(conn, failureUpdate, [currentTime]); + 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 { - if (conn) conn.release(); + await connectionManager.closeConnection(); } } 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 b448ae36..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,50 +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 { - 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, @@ -93,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( @@ -112,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 43f23c52..512ee90b 100644 --- a/frontend/app/api/validations/procedures/[validationType]/route.ts +++ b/frontend/app/api/validations/procedures/[validationType]/route.ts @@ -2,12 +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(); + 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 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 ( @@ -323,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); @@ -388,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}} ); }; @@ -586,7 +586,7 @@ export const MeasurementsSummaryViewGridColumns: GridColDef[] = [ field: 'measuredDBH', headerName: 'DBH', headerClassName: 'header', - flex: 0.8, + flex: 0.5, align: 'right', editable: true, renderCell: renderDBHCell, @@ -607,7 +607,7 @@ export const MeasurementsSummaryViewGridColumns: GridColDef[] = [ field: 'description', headerName: 'Description', headerClassName: 'header', - flex: 1, + flex: 0.6, align: 'left', editable: true }, diff --git a/frontend/components/client/validationmodal.tsx b/frontend/components/client/validationmodal.tsx index 48e70303..325899ba 100644 --- a/frontend/components/client/validationmodal.tsx +++ b/frontend/components/client/validationmodal.tsx @@ -3,37 +3,40 @@ import React, { useEffect, useState } from 'react'; import { Box, LinearProgress, Typography } from '@mui/material'; import CircularProgress from '@mui/joy/CircularProgress'; import { useOrgCensusContext, usePlotContext, useSiteContext } from '@/app/contexts/userselectionprovider'; -import { ValidationResponse } from '@/components/processors/processormacros'; -import { updateValidatedRows } from '@/components/processors/processorhelperfunctions'; +import { Modal, ModalDialog } from '@mui/joy'; +import { CoreMeasurementsRDS } from '@/config/sqlrdsdefinitions/core'; type ValidationMessages = { - [key: string]: { description: string; definition: string }; + [key: string]: { id: number; description: string; definition: string }; }; -const ValidationModal: React.FC = () => { +interface VMProps { + isValidationModalOpen: boolean; + handleCloseValidationModal: () => Promise; +} + +function ValidationModal(props: VMProps) { + const { isValidationModalOpen, handleCloseValidationModal } = props; const [validationMessages, setValidationMessages] = useState({}); - const [validationResults, setValidationResults] = useState>({}); const [isValidationComplete, setIsValidationComplete] = useState(false); const [errorsFound, setErrorsFound] = useState(false); const [apiErrors, setApiErrors] = useState([]); const [validationProgress, setValidationProgress] = useState>({}); - const [countdown, setCountdown] = useState(5); - const [isUpdatingRows, setIsUpdatingRows] = useState(false); // New state for row update status + const [isUpdatingRows, setIsUpdatingRows] = useState(false); + const [rowsPassed, setRowsPassed] = useState([]); const currentSite = useSiteContext(); const currentPlot = usePlotContext(); const currentCensus = useOrgCensusContext(); - const schema = currentSite?.schemaName; const plotID = currentPlot?.plotID; useEffect(() => { - console.log('Loading validation procedures...'); - fetch('/api/validations/validationlist', { method: 'GET' }) + fetch(`/api/validations/validationlist?schema=${currentSite?.schemaName}`, { method: 'GET' }) .then(response => response.json()) .then(data => { - setValidationMessages(data); - const initialProgress = Object.keys(data).reduce((acc, api) => ({ ...acc, [api]: 0 }), {}); + setValidationMessages(data.coreValidations); + const initialProgress = Object.keys(data.coreValidations).reduce((acc, api) => ({ ...acc, [api]: 0 }), {}); setValidationProgress(initialProgress); }) .catch(error => { @@ -43,194 +46,175 @@ const ValidationModal: React.FC = () => { useEffect(() => { if (Object.keys(validationMessages).length > 0) { - performNextValidation(0, false).catch(console.error); + performValidations().catch(console.error); } }, [validationMessages]); - const performNextValidation = async (index: number, foundError: boolean = false) => { - if (index >= Object.keys(validationMessages).length) { + const performValidations = async () => { + try { + const validationProcedureNames = Object.keys(validationMessages); + + const results = await Promise.all( + validationProcedureNames.map(async procedureName => { + const { id: validationProcedureID, definition: cursorQuery } = validationMessages[procedureName]; + + try { + const response = await fetch(`/api/validations/procedures/${procedureName}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + schema: currentSite?.schemaName, + validationProcedureID, + cursorQuery, + p_CensusID: currentCensus?.dateRanges[0].censusID, + p_PlotID: plotID, + minDBH: null, + maxDBH: null, + minHOM: null, + maxHOM: null + }) + }); + + if (!response.ok) { + throw new Error(`Error executing ${procedureName}`); + } + + const result: boolean = await response.json(); + setValidationProgress(prevProgress => ({ + ...prevProgress, + [procedureName]: 100 + })); + + return { procedureName, hasError: result }; + } catch (error: any) { + console.error(`Error performing validation for ${procedureName}:`, error); + setApiErrors(prev => [...prev, `Failed to execute ${procedureName}: ${error.message}`]); + setValidationProgress(prevProgress => ({ + ...prevProgress, + [procedureName]: -1 + })); + return { procedureName, hasError: true }; + } + }) + ); + + const errorsExist = results.some(({ hasError }) => hasError); + try { - setIsUpdatingRows(true); // Indicate that the update is starting - await updateValidatedRows(schema!, { p_CensusID: currentCensus?.dateRanges[0]?.censusID, p_PlotID: currentPlot?.plotID }); // Call the updateValidatedRows - // function - // here - setIsUpdatingRows(false); // Indicate that the update is complete - setIsValidationComplete(true); - setErrorsFound(foundError); + setIsUpdatingRows(true); + const response = await fetch( + `/api/validations/updatepassedvalidations?schema=${currentSite?.schemaName}&plotID=${plotID}&censusID=${currentCensus?.dateRanges[0].censusID}`, + { method: 'GET' } + ); + setRowsPassed(await response.json()); + setErrorsFound(errorsExist); } catch (error: any) { console.error('Error in updating validated rows:', error); setApiErrors(prev => [...prev, `Failed to update validated rows: ${error.message}`]); - setIsUpdatingRows(false); // Ensure the flag is reset even on error + } finally { + setIsUpdatingRows(false); + setIsValidationComplete(true); } - return; - } - - const validationProcedureName = Object.keys(validationMessages)[index]; - const validationProcedureID = index + 1; // Assuming a 1-based index as an ID for simplicity; adjust as needed. - const cursorQuery = validationMessages[validationProcedureName].definition; - - try { - const { response, hasError } = await performValidation(validationProcedureName, validationProcedureID, cursorQuery); - setValidationResults(prevResults => ({ - ...prevResults, - [validationProcedureName]: response - })); - setValidationProgress(prevProgress => ({ ...prevProgress, [validationProcedureName]: 100 })); - await performNextValidation(index + 1, foundError || hasError); } catch (error) { - console.error(`Error in performNextValidation for ${validationProcedureName}:`, error); - } - }; - - const performValidation = async ( - validationProcedureName: string, - validationProcedureID: number, - cursorQuery: string - ): Promise<{ response: ValidationResponse; hasError: boolean }> => { - try { - const response = await fetch(`/api/validations/procedures/${validationProcedureName}`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - schema, - validationProcedureID, - cursorQuery, - p_CensusID: currentCensus?.dateRanges[0].censusID, - p_PlotID: plotID, - minDBH: null, // Adjust these values as needed - maxDBH: null, - minHOM: null, - maxHOM: null - }) - }); - - if (!response.ok) { - throw new Error(`Error executing ${validationProcedureName}`); - } - - const result = await response.json(); - const hasError = result.failedRows > 0; - return { response: result, hasError }; - } catch (error: any) { - console.error(`Error performing validation for ${validationProcedureName}:`, error); - setApiErrors(prev => [...prev, `Failed to execute ${validationProcedureName}: ${error.message}`]); - setValidationProgress(prevProgress => ({ ...prevProgress, [validationProcedureName]: -1 })); - return { - response: { failedRows: 0, message: error.message, totalRows: 0 }, - hasError: false - }; + console.error('Error during validation process:', error); } }; const renderProgressBars = () => { return Object.keys(validationMessages).map(validationProcedureName => ( - {validationMessages[validationProcedureName]?.description || validationProcedureName} + {validationProcedureName} + {validationMessages[validationProcedureName]?.description} )); }; useEffect(() => { - let timer: number; - - if (isValidationComplete && countdown > 0) { - timer = window.setTimeout(() => setCountdown(countdown - 1), 1000) as unknown as number; - } else if (countdown === 0) { - // Automatically close modal or perform any final actions needed after validation - // Example: closeModal(); - } - - return () => clearTimeout(timer); - }, [countdown, isValidationComplete]); + if (isValidationComplete) handleCloseValidationModal().catch(console.error); + }, [isValidationComplete]); return ( - <> - {Object.keys(validationMessages).length > 0 && ( - - {!isValidationComplete ? ( - - Validating data... - {renderProgressBars()} - - ) : isUpdatingRows ? ( // Show updating message when update is in progress - - - Updating validated rows... - - ) : ( - + + + {Object.keys(validationMessages).length > 0 && ( + + {!isValidationComplete ? ( + + Validating data... + {renderProgressBars()} + + ) : isUpdatingRows ? ( - {countdown} seconds remaining + Updating validated rows... - Validation Results - {apiErrors.length > 0 && ( - - Some validations could not be performed: - {apiErrors.map(error => ( - - - {error} - + ) : ( + + Validation Results + {apiErrors.length > 0 && ( + + Some validations could not be performed: + {apiErrors.map(error => ( + + - {error} + + ))} + + )} + {rowsPassed.length > 0 && + rowsPassed.map(row => ( + + Updated Row: {row.coreMeasurementID} + ))} - - )} - {Object.entries(validationResults).map(([validationProcedureName, result]) => ( - - {validationProcedureName}: - {result.failedRows > 0 ? ( - <> - - {result.message} - Failed Core Measurement IDs: {result.failedCoreMeasurementIDs?.join(', ') ?? 'None'} - - ) : ( - - Processed Rows: {result.totalRows}, Errors Detected: {result.failedRows} - - )} - - ))} - - )} - - )} - + + )} + + )} + + ); -}; +} export default ValidationModal; diff --git a/frontend/components/datagrids/applications/isolated/isolatedalltaxonomiesdatagrid.tsx b/frontend/components/datagrids/applications/isolated/isolatedalltaxonomiesdatagrid.tsx index ee57873a..609025a1 100644 --- a/frontend/components/datagrids/applications/isolated/isolatedalltaxonomiesdatagrid.tsx +++ b/frontend/components/datagrids/applications/isolated/isolatedalltaxonomiesdatagrid.tsx @@ -2,7 +2,7 @@ 'use client'; import { GridColDef, GridRenderEditCellParams } from '@mui/x-data-grid'; import React, { useEffect, useState } from 'react'; -import { Box, Button, DialogContent, DialogTitle, Modal, ModalClose, ModalDialog, Stack, Typography } from '@mui/joy'; +import { Box, Button, DialogContent, DialogTitle, Modal, ModalClose, ModalDialog } from '@mui/joy'; import { useSession } from 'next-auth/react'; import UploadParentModal from '@/components/uploadsystemhelpers/uploadparentmodal'; import { FormType } from '@/config/macros/formdetails'; @@ -273,37 +273,6 @@ export default function IsolatedAllTaxonomiesViewDataGrid() { return ( <> - - - - {session?.user.userStatus !== 'field crew' && ( - - Note: ADMINISTRATOR VIEW - - )} - - - - - - - - - { @@ -335,6 +304,10 @@ export default function IsolatedAllTaxonomiesViewDataGrid() { Species: ['speciesCode', 'speciesName', 'speciesIDLevel', 'speciesAuthority', 'fieldFamily', 'validCode', 'speciesDescription'], Subspecies: ['subspeciesName', 'subspeciesAuthority'] }} + dynamicButtons={[ + { label: 'Manual Entry Form', onClick: () => setIsManualEntryFormOpen(true) }, + { label: 'Upload', onClick: () => setIsUploadModalOpen(true) } + ]} /> {}} sx={{ display: 'flex', flex: 1, alignItems: 'center', justifyContent: 'center' }}> diff --git a/frontend/components/datagrids/applications/isolated/isolatedattributesdatagrid.tsx b/frontend/components/datagrids/applications/isolated/isolatedattributesdatagrid.tsx index 3a3dfed9..2c011d4c 100644 --- a/frontend/components/datagrids/applications/isolated/isolatedattributesdatagrid.tsx +++ b/frontend/components/datagrids/applications/isolated/isolatedattributesdatagrid.tsx @@ -2,14 +2,11 @@ // isolated attributes datagrid import React, { useState } from 'react'; -import { Box, Button, Stack, Typography } from '@mui/joy'; -import { useSession } from 'next-auth/react'; import UploadParentModal from '@/components/uploadsystemhelpers/uploadparentmodal'; import { AttributeGridColumns } from '@/components/client/datagridcolumns'; import { FormType } from '@/config/macros/formdetails'; import IsolatedDataGridCommons from '@/components/datagrids/isolateddatagridcommons'; import MultilineModal from '@/components/datagrids/applications/multiline/multilinemodal'; -import { useSiteContext } from '@/app/contexts/userselectionprovider'; export default function IsolatedAttributesDataGrid() { const initialAttributesRDSRow = { @@ -21,42 +18,9 @@ export default function IsolatedAttributesDataGrid() { const [refresh, setRefresh] = useState(false); const [isUploadModalOpen, setIsUploadModalOpen] = useState(false); const [isManualEntryFormOpen, setIsManualEntryFormOpen] = useState(false); - const { data: session } = useSession(); - const currentSite = useSiteContext(); return ( <> - - - - {session?.user.userStatus !== 'field crew' && ( - - Note: ADMINISTRATOR VIEW - - )} - - - - - - - - - { @@ -87,6 +51,10 @@ export default function IsolatedAttributesDataGrid() { Description: ['description'], Status: ['status'] }} + dynamicButtons={[ + { label: 'Manual Entry Form', onClick: () => setIsManualEntryFormOpen(true) }, + { label: 'Upload', onClick: () => setIsUploadModalOpen(true) } + ]} /> ); diff --git a/frontend/components/datagrids/applications/isolated/isolatedmsvstagingdatagrid.tsx b/frontend/components/datagrids/applications/isolated/isolatedmsvstagingdatagrid.tsx index c6adc00b..6ed934b6 100644 --- a/frontend/components/datagrids/applications/isolated/isolatedmsvstagingdatagrid.tsx +++ b/frontend/components/datagrids/applications/isolated/isolatedmsvstagingdatagrid.tsx @@ -3,8 +3,6 @@ import { MeasurementsSummaryStagingRDS } from '@/config/sqlrdsdefinitions/views'; import { useOrgCensusContext, usePlotContext } from '@/app/contexts/userselectionprovider'; import React, { useState } from 'react'; -import { useSession } from 'next-auth/react'; -import { Box, Button, Typography } from '@mui/joy'; import UploadParentModal from '@/components/uploadsystemhelpers/uploadparentmodal'; import { FormType } from '@/config/macros/formdetails'; import IsolatedDataGridCommons from '@/components/datagrids/isolateddatagridcommons'; @@ -29,8 +27,8 @@ export default function IsolatedMeasurementsSummaryDraftDataGrid() { speciesCode: '', treeTag: '', stemTag: '', - localX: 0, - localY: 0, + stemLocalX: 0, + stemLocalY: 0, coordinateUnits: '', measurementDate: null, measuredDBH: 0, @@ -42,48 +40,10 @@ export default function IsolatedMeasurementsSummaryDraftDataGrid() { attributes: '' }; const [refresh, setRefresh] = useState(false); - const { data: session } = useSession(); const [isUploadModalOpen, setIsUploadModalOpen] = useState(false); return ( <> - - - - {session?.user.userStatus !== 'field crew' && ( - - Note: ADMINISTRATOR VIEW - - )} - - Note: This is a locked view and will not allow modification. - - - Please use this view as a way to confirm changes made to measurements. - - - - {/* Upload Button */} - - - { @@ -99,6 +59,7 @@ export default function IsolatedMeasurementsSummaryDraftDataGrid() { setRefresh={setRefresh} initialRow={initialMeasurementsSummaryStagingRDSRow} fieldToFocus={'quadratName'} + dynamicButtons={[{ label: 'Upload', onClick: () => setIsUploadModalOpen(true) }]} /> ); diff --git a/frontend/components/datagrids/applications/isolated/isolatedpersonneldatagrid.tsx b/frontend/components/datagrids/applications/isolated/isolatedpersonneldatagrid.tsx index 1bf30e80..a482e716 100644 --- a/frontend/components/datagrids/applications/isolated/isolatedpersonneldatagrid.tsx +++ b/frontend/components/datagrids/applications/isolated/isolatedpersonneldatagrid.tsx @@ -5,7 +5,6 @@ import React, { useEffect, useState } from 'react'; import { useSession } from 'next-auth/react'; import { Box, Button, Chip, IconButton, Modal, ModalDialog, Stack, Typography } from '@mui/joy'; import UploadParentModal from '@/components/uploadsystemhelpers/uploadparentmodal'; -import Link from 'next/link'; import { FormType } from '@/config/macros/formdetails'; import { PersonnelGridColumns } from '@/components/client/datagridcolumns'; import { useOrgCensusContext, useSiteContext } from '@/app/contexts/userselectionprovider'; @@ -89,17 +88,11 @@ export default function IsolatedPersonnelDataGrid() { }} > - {session?.user.userStatus !== 'field crew' && ( + {session && ( - Note: ADMINISTRATOR VIEW + Role: {session.user.userStatus} )} - - Note: This is a locked view and will not allow modification. - - - Please use this view as a way to confirm changes made to measurements. - @@ -111,11 +104,6 @@ export default function IsolatedPersonnelDataGrid() { Upload - - - @@ -174,6 +162,12 @@ export default function IsolatedPersonnelDataGrid() { Name: ['firstName', 'lastName'], Role: ['roleID'] }} + dynamicButtons={[ + { label: 'Manual Entry Form', onClick: () => setIsManualEntryFormOpen(true) }, + { label: 'Upload', onClick: () => setIsUploadModalOpen(true) }, + { label: 'View Quadrat Personnel', onClick: () => console.log('View Quadrat Personnel clicked') }, + { label: 'Edit Roles', onClick: () => setIsRolesModalOpen(true) } + ]} /> ); diff --git a/frontend/components/datagrids/applications/isolated/isolatedquadratpersonneldatagrid.tsx b/frontend/components/datagrids/applications/isolated/isolatedquadratpersonneldatagrid.tsx index e821002b..d256e49f 100644 --- a/frontend/components/datagrids/applications/isolated/isolatedquadratpersonneldatagrid.tsx +++ b/frontend/components/datagrids/applications/isolated/isolatedquadratpersonneldatagrid.tsx @@ -121,6 +121,7 @@ export default function IsolatedQuadratPersonnelDataGrid() { setRefresh={setRefresh} initialRow={initialQuadratPersonnelRDSRow} fieldToFocus={'quadratID'} + dynamicButtons={[]} /> ); diff --git a/frontend/components/datagrids/applications/isolated/isolatedquadratsdatagrid.tsx b/frontend/components/datagrids/applications/isolated/isolatedquadratsdatagrid.tsx index 95552b79..63ea57ae 100644 --- a/frontend/components/datagrids/applications/isolated/isolatedquadratsdatagrid.tsx +++ b/frontend/components/datagrids/applications/isolated/isolatedquadratsdatagrid.tsx @@ -1,10 +1,8 @@ // quadrats datagrid 'use client'; import React, { useState } from 'react'; -import { Box, Button, Stack, Typography } from '@mui/joy'; import { useSession } from 'next-auth/react'; import UploadParentModal from '@/components/uploadsystemhelpers/uploadparentmodal'; -import Link from 'next/link'; import { quadratGridColumns } from '@/components/client/datagridcolumns'; import { FormType } from '@/config/macros/formdetails'; import { QuadratRDS } from '@/config/sqlrdsdefinitions/zones'; @@ -37,47 +35,6 @@ export default function IsolatedQuadratsDataGrid() { return ( <> - - - - {session?.user.userStatus !== 'field crew' && ( - - Note: ADMINISTRATOR VIEW - - )} - - Note: This is a locked view and will not allow modification. - - - Please use this view as a way to confirm changes made to measurements. - - - - - - - - - - - - { @@ -108,6 +65,10 @@ export default function IsolatedQuadratsDataGrid() { Area: ['area', 'areaUnits'], Misc: ['quadratShape'] }} + dynamicButtons={[ + { label: 'Manual Entry Form', onClick: () => setIsManualEntryFormOpen(true) }, + { label: 'Upload', onClick: () => setIsUploadModalOpen(true) } + ]} /> ); diff --git a/frontend/components/datagrids/applications/isolated/isolatedrolesdatagrid.tsx b/frontend/components/datagrids/applications/isolated/isolatedrolesdatagrid.tsx index 380acc36..d330a7d2 100644 --- a/frontend/components/datagrids/applications/isolated/isolatedrolesdatagrid.tsx +++ b/frontend/components/datagrids/applications/isolated/isolatedrolesdatagrid.tsx @@ -1,14 +1,12 @@ // roles datagrid 'use client'; import React, { useState } from 'react'; -import { Box, Typography } from '@mui/joy'; -import { useSession } from 'next-auth/react'; import { RolesGridColumns } from '@/components/client/datagridcolumns'; import { RoleRDS } from '@/config/sqlrdsdefinitions/personnel'; import IsolatedDataGridCommons from '@/components/datagrids/isolateddatagridcommons'; type IsolatedRolesDataGridProps = { - onRolesUpdated: () => void; // Accept the onRolesUpdated prop + onRolesUpdated: () => void; }; export default function IsolatedRolesDataGrid(props: IsolatedRolesDataGridProps) { @@ -20,32 +18,9 @@ export default function IsolatedRolesDataGrid(props: IsolatedRolesDataGridProps) roleDescription: '' }; const [refresh, setRefresh] = useState(false); - const { data: session } = useSession(); return ( <> - - - - {session?.user.userStatus !== 'field crew' && ( - - Note: ADMINISTRATOR VIEW - - )} - - - - ); diff --git a/frontend/components/datagrids/applications/isolated/isolatedstemtaxonomiesviewdatagrid.tsx b/frontend/components/datagrids/applications/isolated/isolatedstemtaxonomiesviewdatagrid.tsx index 272bf0f9..e7e3a1a0 100644 --- a/frontend/components/datagrids/applications/isolated/isolatedstemtaxonomiesviewdatagrid.tsx +++ b/frontend/components/datagrids/applications/isolated/isolatedstemtaxonomiesviewdatagrid.tsx @@ -1,7 +1,6 @@ // stemtaxonomiesview datagrid 'use client'; import React, { useState } from 'react'; -import { Box, Button, Typography } from '@mui/joy'; import { useSession } from 'next-auth/react'; import UploadParentModal from '@/components/uploadsystemhelpers/uploadparentmodal'; import { StemTaxonomiesViewGridColumns } from '@/components/client/datagridcolumns'; @@ -38,33 +37,6 @@ export default function IsolatedStemTaxonomiesViewDataGrid() { return ( <> - - - - {session?.user.userStatus !== 'field crew' && ( - - Note: ADMINISTRATOR VIEW - - )} - - - {/* Upload Button */} - - - - { @@ -90,6 +62,7 @@ export default function IsolatedStemTaxonomiesViewDataGrid() { Species: ['speciesCode', 'speciesName', 'validCode', 'speciesAuthority', 'speciesIDLevel', 'speciesFieldFamily'], Subspecies: ['subspeciesName', 'subspeciesAuthority'] }} + dynamicButtons={[{ label: 'Upload', onClick: () => setIsUploadModalOpen(true) }]} /> ); diff --git a/frontend/components/datagrids/applications/msvdatagrid.tsx b/frontend/components/datagrids/applications/msvdatagrid.tsx index 92296e4c..c1c37879 100644 --- a/frontend/components/datagrids/applications/msvdatagrid.tsx +++ b/frontend/components/datagrids/applications/msvdatagrid.tsx @@ -4,14 +4,13 @@ import { useOrgCensusContext, usePlotContext, useSiteContext } from '@/app/conte import React, { useEffect, useState } from 'react'; import { GridRowModes, GridRowModesModel, GridRowsProp } from '@mui/x-data-grid'; import { randomId } from '@mui/x-data-grid-generator'; -import { Box, Button, Snackbar, Stack, Typography } from '@mui/joy'; +import { Snackbar } from '@mui/joy'; import UploadParentModal from '@/components/uploadsystemhelpers/uploadparentmodal'; import MeasurementsCommons from '@/components/datagrids/measurementscommons'; import { MeasurementsSummaryViewGridColumns } from '@/components/client/datagridcolumns'; import { FormType } from '@/config/macros/formdetails'; import { MeasurementsSummaryRDS } from '@/config/sqlrdsdefinitions/views'; import MultilineModal from '@/components/datagrids/applications/multiline/multilinemodal'; -import { useLoading } from '@/app/contexts/loadingprovider'; import { Alert, AlertProps, AlertTitle, Collapse } from '@mui/material'; const initialMeasurementsSummaryViewRDSRow: MeasurementsSummaryRDS = { @@ -29,8 +28,8 @@ const initialMeasurementsSummaryViewRDSRow: MeasurementsSummaryRDS = { speciesCode: '', treeTag: '', stemTag: '', - localX: 0, - localY: 0, + stemLocalX: 0, + stemLocalY: 0, coordinateUnits: '', measurementDate: null, measuredDBH: 0, @@ -80,7 +79,6 @@ export default function MeasurementsSummaryViewDataGrid() { const addNewRowToGrid = () => { const id = randomId(); - // Define new row structure based on MeasurementsSummaryRDS type const newRow = { ...initialMeasurementsSummaryViewRDSRow, id: id, @@ -113,46 +111,6 @@ export default function MeasurementsSummaryViewDataGrid() { )} - - - - - - - Note: This plot does not accept subquadrats.
- Please ensure that you use quadrat names when submitting new measurements instead of subquadrat names -
-
-
-
- - - - -
-
{ @@ -190,6 +148,10 @@ export default function MeasurementsSummaryViewDataGrid() { shouldAddRowAfterFetch={shouldAddRowAfterFetch} setShouldAddRowAfterFetch={setShouldAddRowAfterFetch} addNewRowToGrid={addNewRowToGrid} + dynamicButtons={[ + { label: 'Manual Entry Form', onClick: () => setIsManualEntryFormOpen(true) }, + { label: 'Upload', onClick: () => setIsUploadModalOpen(true) } + ]} /> { const handleExportClick = async () => { if (!handleExportAll) return; - const fullData = await handleExportAll(filterModel); + const fullData = await handleExportAll(); const blob = new Blob([JSON.stringify(fullData, null, 2)], { type: 'application/json' }); diff --git a/frontend/components/datagrids/applications/viewfulltabledatagrid.tsx b/frontend/components/datagrids/applications/viewfulltabledatagrid.tsx index d8707c68..ab525ef8 100644 --- a/frontend/components/datagrids/applications/viewfulltabledatagrid.tsx +++ b/frontend/components/datagrids/applications/viewfulltabledatagrid.tsx @@ -1,11 +1,9 @@ // viewfulltable view datagrid 'use client'; -import { Box, Typography } from '@mui/joy'; import { AlertProps } from '@mui/material'; import { GridRowsProp } from '@mui/x-data-grid'; import { randomId } from '@mui/x-data-grid-generator'; -import { useSession } from 'next-auth/react'; import { useEffect, useState } from 'react'; import { ViewFullTableGridColumns } from '@/components/client/datagridcolumns'; import MeasurementsCommons from '@/components/datagrids/measurementscommons'; @@ -120,7 +118,6 @@ export default function ViewFullTableDataGrid() { }); const [isNewRowAdded, setIsNewRowAdded] = useState(false); const [shouldAddRowAfterFetch, setShouldAddRowAfterFetch] = useState(false); - const { data: session } = useSession(); const { setLoading } = useLoading(); const currentSite = useSiteContext(); @@ -140,71 +137,57 @@ export default function ViewFullTableDataGrid() { }; async function reloadVFT() { - setLoading(true, 'Refreshing Historical View...'); - const response = await fetch(`/api/refreshviews/viewfulltable/${currentSite?.schemaName ?? ''}`, { method: 'POST' }); - if (!response.ok) throw new Error('Historical View Refresh failure'); - await new Promise(resolve => setTimeout(resolve, 1000)); + try { + setLoading(true, 'Refreshing Historical View...'); + const startTime = Date.now(); + const response = await fetch(`/api/refreshviews/viewfulltable/${currentSite?.schemaName ?? ''}`, { method: 'POST' }); + if (!response.ok) throw new Error('Historical View Refresh failure'); + setLoading(true, 'Processing data...'); + await response.json(); + const duration = (Date.now() - startTime) / 1000; + setLoading(true, `Completed in ${duration.toFixed(2)} seconds.`); + await new Promise(resolve => setTimeout(resolve, 1000)); + } catch (e: any) { + console.error(e); + } finally { + setLoading(false); + } } useEffect(() => { - reloadVFT() - .catch(console.error) - .then(() => setLoading(false)); - }, []); + let isMounted = true; + if (isMounted) { + reloadVFT().catch(console.error); + } + return () => { + isMounted = false; + }; + }, [refresh]); return ( <> - - - - {session?.user.userStatus !== 'field crew' && ( - - Note: ADMINISTRATOR VIEW - - )} - - - - - + ); } diff --git a/frontend/components/datagrids/datagridcommons.tsx b/frontend/components/datagrids/datagridcommons.tsx index 0bd6f4b5..192e4bce 100644 --- a/frontend/components/datagrids/datagridcommons.tsx +++ b/frontend/components/datagrids/datagridcommons.tsx @@ -54,7 +54,7 @@ type EditToolbarProps = EditToolbarCustomProps & GridToolbarProps & ToolbarProps const EditToolbar = ({ handleAddNewRow, handleRefresh, handleExportAll, locked, filterModel }: EditToolbarProps) => { const handleExportClick = async () => { if (!handleExportAll) return; - const fullData = await handleExportAll(filterModel); + const fullData = await handleExportAll(); const blob = new Blob([JSON.stringify(fullData, null, 2)], { type: 'application/json' }); diff --git a/frontend/components/datagrids/filtrationsystem.tsx b/frontend/components/datagrids/filtrationsystem.tsx new file mode 100644 index 00000000..18eb2425 --- /dev/null +++ b/frontend/components/datagrids/filtrationsystem.tsx @@ -0,0 +1,133 @@ +'use client'; + +import { GetApplyQuickFilterFn, GridColDef, GridFilterInputValueProps, GridFilterOperator, useGridRootProps } from '@mui/x-data-grid'; +import { TextField, TextFieldProps } from '@mui/material'; +import { useEffect, useRef, useState } from 'react'; +import { Box } from '@mui/joy'; +import SyncIcon from '@mui/icons-material/Sync'; + +// starting with quick filter +export const getApplyQuickFilterFnSameYear: GetApplyQuickFilterFn = value => { + if (!value || value.length !== 4 || !/\d{4}/.test(value)) { + return null; + } + return cellValue => { + if (cellValue instanceof Date) { + return cellValue.getFullYear() === Number(value); + } + return false; + }; +}; + +export function applyFilterToColumns(columns: GridColDef[]) { + return columns.map(column => { + if (column.field === 'dateCreated') { + return { + ...column, + getApplyQuickFilterFn: getApplyQuickFilterFnSameYear + }; + } + if (column.field === 'name') { + return { + ...column, + getApplyQuickFilterFn: undefined + }; + } + return column; + }); +} + +// customizing full filtration system +// multiple values operator: +function InputNumberInterval(props: GridFilterInputValueProps) { + const rootProps = useGridRootProps(); + const { item, applyValue, focusElementRef = null } = props; + + const filterTimeout = useRef(); + const [filterValueState, setFilterValueState] = useState<[string, string]>(item.value ?? ''); + const [applying, setIsApplying] = useState(false); + + useEffect(() => { + return () => { + clearTimeout(filterTimeout.current); + }; + }, []); + + useEffect(() => { + const itemValue = item.value ?? [undefined, undefined]; + setFilterValueState(itemValue); + }, [item.value]); + + const updateFilterValue = (lowerBound: string, upperBound: string) => { + clearTimeout(filterTimeout.current); + setFilterValueState([lowerBound, upperBound]); + + setIsApplying(true); + filterTimeout.current = setTimeout(() => { + setIsApplying(false); + applyValue({ ...item, value: [lowerBound, upperBound] }); + }, rootProps.filterDebounceMs); + }; + + const handleUpperFilterChange: TextFieldProps['onChange'] = event => { + const newUpperBound = event.target.value; + updateFilterValue(filterValueState[0], newUpperBound); + }; + const handleLowerFilterChange: TextFieldProps['onChange'] = event => { + const newLowerBound = event.target.value; + updateFilterValue(newLowerBound, filterValueState[1]); + }; + + return ( + + + } : undefined + }} + /> + + ); +} + +export const betweenOperator: GridFilterOperator = { + label: 'Between', + value: 'between', + getApplyFilterFn: filterItem => { + if (!Array.isArray(filterItem.value) || filterItem.value.length !== 2) { + return null; + } + if (filterItem.value[0] == null || filterItem.value[1] == null) { + return null; + } + return (value: number) => { + return value != null && filterItem.value[0] <= value && value <= filterItem.value[1]; + }; + }, + InputComponent: InputNumberInterval +}; diff --git a/frontend/components/datagrids/isolateddatagridcommons.tsx b/frontend/components/datagrids/isolateddatagridcommons.tsx index b4ec5f18..50e9a1ab 100644 --- a/frontend/components/datagrids/isolateddatagridcommons.tsx +++ b/frontend/components/datagrids/isolateddatagridcommons.tsx @@ -5,6 +5,7 @@ import { createDeleteQuery, createFetchQuery, createPostPatchQuery, + createQFFetchQuery, EditToolbarCustomProps, filterColumns, getColumnVisibilityModel, @@ -23,23 +24,21 @@ import { GridRowModes, GridRowModesModel, GridRowsProp, - GridToolbar, GridToolbarContainer, GridToolbarProps, + GridToolbarQuickFilter, ToolbarPropsOverrides, useGridApiRef } from '@mui/x-data-grid'; -import { Alert, AlertProps, Button, Snackbar } from '@mui/material'; +import { Alert, AlertProps, Button, IconButton, Snackbar } from '@mui/material'; import AddIcon from '@mui/icons-material/Add'; import RefreshIcon from '@mui/icons-material/Refresh'; -import FileDownloadIcon from '@mui/icons-material/FileDownload'; import React, { useCallback, useEffect, useMemo, useState } from 'react'; import { useOrgCensusContext, usePlotContext, useQuadratContext, useSiteContext } from '@/app/contexts/userselectionprovider'; -import { useLoading } from '@/app/contexts/loadingprovider'; import { useDataValidityContext } from '@/app/contexts/datavalidityprovider'; import { useSession } from 'next-auth/react'; import { HTTPResponses, UnifiedValidityFlags } from '@/config/macros'; -import { Tooltip, Typography } from '@mui/joy'; +import { Dropdown, Menu, MenuButton, MenuItem, Stack, Tooltip } from '@mui/joy'; import SaveIcon from '@mui/icons-material/Save'; import CancelIcon from '@mui/icons-material/Close'; import EditIcon from '@mui/icons-material/Edit'; @@ -50,49 +49,155 @@ import { StyledDataGrid } from '@/config/styleddatagrid'; import ConfirmationDialog from '@/components/datagrids/confirmationdialog'; import { randomId } from '@mui/x-data-grid-generator'; import SkipReEnterDataModal from '@/components/datagrids/skipreentrydatamodal'; -import { FileDownloadTwoTone } from '@mui/icons-material'; import { FormType, getTableHeaders } from '@/config/macros/formdetails'; +import { applyFilterToColumns } from '@/components/datagrids/filtrationsystem'; +import { ClearIcon } from '@mui/x-date-pickers'; +import CloudDownloadIcon from '@mui/icons-material/CloudDownload'; type EditToolbarProps = EditToolbarCustomProps & GridToolbarProps & ToolbarPropsOverrides; -const EditToolbar = ({ handleAddNewRow, handleRefresh, handleExportAll, handleExportCSV, locked, filterModel }: EditToolbarProps) => { - if (!handleAddNewRow || !handleRefresh) return <>; - const handleExportClick = async () => { - if (!handleExportAll) return; - const fullData = await handleExportAll(filterModel); - const blob = new Blob([JSON.stringify(fullData, null, 2)], { - type: 'application/json' +const EditToolbar = (props: EditToolbarProps) => { + const { handleAddNewRow, handleRefresh, handleExportAll, handleExportCSV, handleQuickFilterChange, locked, filterModel, dynamicButtons = [] } = props; + if (!handleAddNewRow || !handleRefresh || !handleQuickFilterChange || !handleExportAll) return <>; + const [inputValue, setInputValue] = useState(''); + const [isTyping, setIsTyping] = useState(false); + + const handleInputChange = (event: React.ChangeEvent) => { + setInputValue(event.target.value); + setIsTyping(true); + }; + + const handleKeyDown = (event: React.KeyboardEvent) => { + if (event.key === 'Enter') { + handleQuickFilterChange({ + ...filterModel, + items: filterModel?.items || [], + quickFilterValues: inputValue.split(' ') || [] + }); + setIsTyping(false); + } + }; + + const handleClearInput = () => { + setInputValue(''); + handleQuickFilterChange({ + ...filterModel, + items: filterModel?.items || [], + quickFilterValues: [] }); + setIsTyping(false); + }; + + useEffect(() => { + if (isTyping) { + const timeout = setTimeout(() => setIsTyping(false), 2000); + return () => clearTimeout(timeout); + } + }, [isTyping, inputValue]); + + function exportFilterModel() { + const jsonData = JSON.stringify(filterModel, null, 2); + const blob = new Blob([jsonData], { type: 'application/json' }); const url = URL.createObjectURL(blob); + const link = document.createElement('a'); link.href = url; - link.download = 'data.json'; - document.body.appendChild(link); + link.download = 'results.json'; link.click(); - document.body.removeChild(link); - }; + + URL.revokeObjectURL(url); + } return ( - - - - - + + + + + + + + + + + + + + + + + } + > + Export... + + + await handleExportAll()}> + All data as JSON + + + All Data as Form + + + Filter Settings + + + + + + + {dynamicButtons.map((button: any, index: number) => ( + + ))} + ); }; export default function IsolatedDataGridCommons(props: Readonly) { - const { gridColumns, gridType, refresh, setRefresh, locked = false, selectionOptions, initialRow, fieldToFocus, clusters } = props; + const { gridColumns, gridType, refresh, setRefresh, locked = false, selectionOptions, initialRow, fieldToFocus, clusters, dynamicButtons = [] } = props; const [rows, setRows] = useState([initialRow] as GridRowsProp); const [rowCount, setRowCount] = useState(0); @@ -102,11 +207,13 @@ export default function IsolatedDataGridCommons(props: Readonly(null); const [isDialogOpen, setIsDialogOpen] = useState(false); const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false); + const [usingQuery, setUsingQuery] = useState(''); const [pendingAction, setPendingAction] = useState({ actionType: '', actionId: null @@ -118,81 +225,45 @@ export default function IsolatedDataGridCommons(props: Readonly(null); const [filterModel, setFilterModel] = useState({ - items: [] + items: [], + quickFilterValues: [] }); - const [rowsUpdated, setRowsUpdated] = useState(false); - const [rowModesModelUpdated, setRowModesModelUpdated] = useState(false); const currentPlot = usePlotContext(); const currentCensus = useOrgCensusContext(); const currentQuadrat = useQuadratContext(); const currentSite = useSiteContext(); - const { setLoading } = useLoading(); const { triggerRefresh } = useDataValidityContext(); useSession(); const apiRef = useGridApiRef(); - // Track when rows and rowModesModel are updated using useEffect - useEffect(() => { - if (rows.length > 0) { - setRowsUpdated(true); - } - }, [rows]); - - useEffect(() => { - if (Object.keys(rowModesModel).length > 0) { - setRowModesModelUpdated(true); - } - }, [rowModesModel]); - - // Function to wait for rows and rowModesModel to update - const waitForStateUpdates = async () => { - return new Promise(resolve => { - const checkUpdates = () => { - if (rowsUpdated && rowModesModelUpdated) { - resolve(); - } else { - setTimeout(checkUpdates, 50); // Check every 50ms until both are updated - } - }; - checkUpdates(); - }); - }; - - useEffect(() => { - if (!isNewRowAdded) { - fetchPaginatedData(paginationModel.page).catch(console.error); - } - }, [paginationModel.page]); - useEffect(() => { - if (currentPlot?.plotID || currentCensus?.plotCensusNumber) { + if (currentPlot?.plotID || currentCensus?.plotCensusNumber || !isNewRowAdded) { fetchPaginatedData(paginationModel.page).catch(console.error); } - }, [currentPlot, currentCensus, paginationModel.page]); + }, [currentPlot, currentCensus, paginationModel.page, filterModel]); useEffect(() => { if (refresh && currentSite) { handleRefresh().then(() => { if (refresh) { - setRefresh(false); // Only update state if it hasn't already been reset + setRefresh(false); } }); } - }, [refresh, currentSite]); // No need for setRefresh in dependencies + }, [refresh, currentSite]); useEffect(() => { const updatedRowModesModel = rows.reduce((acc, row) => { if (row.id) { - acc[row.id] = rowModesModel[row.id] || { mode: GridRowModes.View }; // Ensure valid row ID is used + acc[row.id] = rowModesModel[row.id] || { mode: GridRowModes.View }; } return acc; }, {} as GridRowModesModel); - // Clean invalid rowModesModel entries like '0' const cleanedRowModesModel = Object.fromEntries(Object.entries(updatedRowModesModel).filter(([key]) => key !== '0')); if (JSON.stringify(cleanedRowModesModel) !== JSON.stringify(rowModesModel)) { @@ -201,30 +272,226 @@ export default function IsolatedDataGridCommons(props: Readonly { - setLoading(true, 'Fetching full dataset...'); - let partialQuery = ``; - if (currentPlot?.plotID) partialQuery += `/${currentPlot.plotID}`; - if (currentCensus?.plotCensusNumber) partialQuery += `/${currentCensus.plotCensusNumber}`; - if (currentQuadrat?.quadratID) partialQuery += `/${currentQuadrat.quadratID}`; - const fullDataQuery = `/api/fetchall/${gridType}` + partialQuery + `?schema=${currentSite?.schemaName}`; - + setLoading(true); try { - const response = await fetch(fullDataQuery, { - method: 'GET', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(filterModel) - }); - const data = await response.json(); - if (!response.ok) throw new Error(data.message || 'Error fetching full data'); - return data.output; + const reworkedQuery = usingQuery + .replace(/\bSQL_CALC_FOUND_ROWS\b\s*/i, '') + .replace(/\bLIMIT\s+\d+\s*,\s*\d+/i, '') + .trim(); + + const results = await ( + await fetch(`/api/runquery`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(reworkedQuery) + }) + ).json(); + + const jsonData = JSON.stringify(results, null, 2); + const blob = new Blob([jsonData], { type: 'application/json' }); + const url = URL.createObjectURL(blob); + + const link = document.createElement('a'); + link.href = url; + link.download = 'results.json'; + link.click(); + + URL.revokeObjectURL(url); } catch (error) { console.error('Error fetching full data:', error); setSnackbar({ children: 'Error fetching full data', severity: 'error' }); - return []; } finally { setLoading(false); } - }, [filterModel, currentPlot, currentCensus, currentQuadrat, currentSite, gridType, setLoading]); + }, [usingQuery, filterModel, currentPlot, currentCensus, currentQuadrat, currentSite, gridType, setLoading]); + + const exportAllCSV = useCallback(async () => { + setLoading(true); + switch (gridType) { + case 'attributes': + const aResponse = await fetch( + `/api/formdownload/attributes/${currentSite?.schemaName ?? ''}/${currentPlot?.plotID ?? 0}/${currentCensus?.dateRanges[0].censusID ?? 0}`, + { method: 'GET' } + ); + const aData = await aResponse.json(); + let aCSVRows = + getTableHeaders(FormType.attributes) + .map(row => row.label) + .join(',') + '\n'; + aData.forEach((row: any) => { + const values = getTableHeaders(FormType.attributes) + .map(rowHeader => rowHeader.label) + .map(header => row[header]) + .map(value => { + if (value === undefined || value === null || value === '') { + return null; + } + if (typeof value === 'number') { + return value; + } + const parsedValue = parseFloat(value); + if (!isNaN(parsedValue)) { + return parsedValue; + } + if (typeof value === 'string') { + value = value.replace(/"/g, '""'); + value = `"${value}"`; + } + + return value; + }); + aCSVRows += values.join(',') + '\n'; + }); + const aBlob = new Blob([aCSVRows], { + type: 'text/csv;charset=utf-8;' + }); + const aURL = URL.createObjectURL(aBlob); + const aLink = document.createElement('a'); + aLink.href = aURL; + aLink.download = `attributesform_${currentSite?.schemaName ?? ''}_${currentPlot?.plotName ?? ''}_${currentCensus?.plotCensusNumber ?? 0}.csv`; + document.body.appendChild(aLink); + aLink.click(); + document.body.removeChild(aLink); + break; + case 'quadrats': + const qResponse = await fetch( + `/api/formdownload/quadrats/${currentSite?.schemaName ?? ''}/${currentPlot?.plotID ?? 0}/${currentCensus?.dateRanges[0].censusID ?? 0}`, + { method: 'GET' } + ); + const qData = await qResponse.json(); + let qCSVRows = + getTableHeaders(FormType.quadrats) + .map(row => row.label) + .join(',') + '\n'; + qData.forEach((row: any) => { + const values = getTableHeaders(FormType.quadrats) + .map(rowHeader => rowHeader.label) + .map(header => row[header]) + .map(value => { + if (value === undefined || value === null || value === '') { + return null; + } + if (typeof value === 'number') { + return value; + } + const parsedValue = parseFloat(value); + if (!isNaN(parsedValue)) { + return parsedValue; + } + if (typeof value === 'string') { + value = value.replace(/"/g, '""'); + value = `"${value}"`; + } + + return value; + }); + qCSVRows += values.join(',') + '\n'; + }); + const qBlob = new Blob([qCSVRows], { + type: 'text/csv;charset=utf-8;' + }); + const qURL = URL.createObjectURL(qBlob); + const qLink = document.createElement('a'); + qLink.href = qURL; + qLink.download = `quadratsform_${currentSite?.schemaName ?? ''}_${currentPlot?.plotName ?? ''}_${currentCensus?.plotCensusNumber ?? 0}.csv`; + document.body.appendChild(qLink); + qLink.click(); + document.body.removeChild(qLink); + break; + case 'personnel': + const pResponse = await fetch( + `/api/formdownload/personnel/${currentSite?.schemaName ?? ''}/${currentPlot?.plotID ?? 0}/${currentCensus?.dateRanges[0].censusID ?? 0}`, + { method: 'GET' } + ); + const pData = await pResponse.json(); + let pCSVRows = + getTableHeaders(FormType.personnel) + .map(row => row.label) + .join(',') + '\n'; + pData.forEach((row: any) => { + const values = getTableHeaders(FormType.personnel) + .map(rowHeader => rowHeader.label) + .map(header => row[header]) + .map(value => { + if (value === undefined || value === null || value === '') { + return null; + } + if (typeof value === 'number') { + return value; + } + const parsedValue = parseFloat(value); + if (!isNaN(parsedValue)) { + return parsedValue; + } + if (typeof value === 'string') { + value = value.replace(/"/g, '""'); + value = `"${value}"`; + } + + return value; + }); + pCSVRows += values.join(',') + '\n'; + }); + const pBlob = new Blob([pCSVRows], { + type: 'text/csv;charset=utf-8;' + }); + const pURL = URL.createObjectURL(pBlob); + const pLink = document.createElement('a'); + pLink.href = pURL; + pLink.download = `personnelform_${currentSite?.schemaName ?? ''}_${currentPlot?.plotName ?? ''}_${currentCensus?.plotCensusNumber ?? 0}.csv`; + document.body.appendChild(pLink); + pLink.click(); + document.body.removeChild(pLink); + break; + case 'species': + case 'alltaxonomiesview': + const sResponse = await fetch( + `/api/formdownload/species/${currentSite?.schemaName ?? ''}/${currentPlot?.plotID ?? 0}/${currentCensus?.dateRanges[0].censusID ?? 0}`, + { method: 'GET' } + ); + const sData = await sResponse.json(); + let sCSVRows = + getTableHeaders(FormType.species) + .map(row => row.label) + .join(',') + '\n'; + sData.forEach((row: any) => { + const values = getTableHeaders(FormType.species) + .map(rowHeader => rowHeader.label) + .map(header => row[header]) + .map(value => { + if (value === undefined || value === null || value === '') { + return null; + } + if (typeof value === 'number') { + return value; + } + const parsedValue = parseFloat(value); + if (!isNaN(parsedValue)) { + return parsedValue; + } + if (typeof value === 'string') { + value = value.replace(/"/g, '""'); + value = `"${value}"`; + } + + return value; + }); + sCSVRows += values.join(',') + '\n'; + }); + const sBlob = new Blob([sCSVRows], { + type: 'text/csv;charset=utf-8;' + }); + const sURL = URL.createObjectURL(sBlob); + const sLink = document.createElement('a'); + sLink.href = sURL; + sLink.download = `speciesform_${currentSite?.schemaName ?? ''}_${currentPlot?.plotName ?? ''}_${currentCensus?.plotCensusNumber ?? 0}.csv`; + document.body.appendChild(sLink); + sLink.click(); + document.body.removeChild(sLink); + break; + } + setLoading(false); + }, [currentPlot, currentCensus, currentSite, gridType]); const exportAllCSV = useCallback(async () => { switch (gridType) { @@ -421,7 +688,6 @@ export default function IsolatedDataGridCommons(props: Readonly { if (locked || !promiseArguments) return; - setLoading(true, 'Saving changes...'); + setLoading(true); try { - // Set the row to view mode after confirmation setRowModesModel(prevModel => ({ ...prevModel, [id]: { mode: GridRowModes.View } @@ -493,12 +753,13 @@ export default function IsolatedDataGridCommons(props: Readonly { if (locked) return; - setLoading(true, 'Deleting...'); + setLoading(true); const rowToDelete = rows.find(row => String(row.id) === String(id)); - if (!rowToDelete) return; // Ensure row exists + if (!rowToDelete) return; const deleteQuery = createDeleteQuery(currentSite?.schemaName ?? '', gridType, getGridID(gridType), rowToDelete.id); @@ -554,7 +815,7 @@ export default function IsolatedDataGridCommons(props: Readonly prevRows.filter(row => row.id !== id)); // Update rows by removing the deleted row + setRows(prevRows => prevRows.filter(row => row.id !== id)); setSnackbar({ children: 'Row successfully deleted', severity: 'success' @@ -575,33 +836,28 @@ export default function IsolatedDataGridCommons(props: Readonly async () => { + (id: GridRowId) => () => { if (locked) return; const updatedRowModesModel = { ...rowModesModel }; if (!updatedRowModesModel[id] || updatedRowModesModel[id].mode === undefined) { - updatedRowModesModel[id] = { mode: GridRowModes.View }; // Set default mode if it doesn't exist + updatedRowModesModel[id] = { mode: GridRowModes.View }; } - // Stop edit mode and apply changes locally without committing to the server yet apiRef.current.stopRowEditMode({ id, ignoreModifications: true }); - // Get the original row data (before edits) const oldRow = rows.find(row => String(row.id) === String(id)); - // Use getRowWithUpdatedValues to fetch all updated field values (the field is ignored in row editing mode) - const updatedRow = apiRef.current.getRowWithUpdatedValues(id, 'anyField'); // 'anyField' is a dummy value, ignored in row editing + const updatedRow = apiRef.current.getRowWithUpdatedValues(id, 'anyField'); if (oldRow && updatedRow) { - // Set promise arguments before opening the modal setPromiseArguments({ - resolve: (value: GridRowModel) => {}, // Define resolve - reject: (reason?: any) => {}, // Define reject - oldRow, // Pass the old (original) row - newRow: updatedRow // Pass the updated (edited) row + resolve: (value: GridRowModel) => {}, + reject: (reason?: any) => {}, + oldRow, + newRow: updatedRow }); - // Open the confirmation dialog for reentry data openConfirmationDialog('save', id); } }, @@ -623,9 +879,9 @@ export default function IsolatedDataGridCommons(props: Readonly { + const handleAddNewRow = useCallback(() => { if (locked) return; - if (isNewRowAdded) return; // Debounce double adds + if (isNewRowAdded) return; const newRowCount = rowCount + 1; const calculatedNewLastPage = Math.ceil(newRowCount / paginationModel.pageSize) - 1; const existingLastPage = Math.ceil(rowCount / paginationModel.pageSize) - 1; @@ -635,7 +891,7 @@ export default function IsolatedDataGridCommons(props: Readonly { return [...prevRows, newRow]; @@ -643,7 +899,7 @@ export default function IsolatedDataGridCommons(props: Readonly { return { ...prevModel, - [id]: { mode: GridRowModes.Edit, fieldToFocus } // Add the new row with 'Edit' mode + [id]: { mode: GridRowModes.Edit, fieldToFocus } }; }); @@ -657,26 +913,45 @@ export default function IsolatedDataGridCommons(props: Readonly { - setLoading(true, 'Loading data...'); - const paginatedQuery = createFetchQuery( - currentSite?.schemaName ?? '', - gridType, - pageToFetch, - paginationModel.pageSize, - currentPlot?.plotID, - currentCensus?.plotCensusNumber, - currentQuadrat?.quadratID - ); + setLoading(true); + const paginatedQuery = + (filterModel.items && filterModel.items.length > 0) || (filterModel.quickFilterValues && filterModel.quickFilterValues.length > 0) + ? createQFFetchQuery( + currentSite?.schemaName ?? '', + gridType, + pageToFetch, + paginationModel.pageSize, + currentPlot?.plotID, + currentCensus?.plotCensusNumber, + currentQuadrat?.quadratID + ) + : createFetchQuery( + currentSite?.schemaName ?? '', + gridType, + pageToFetch, + paginationModel.pageSize, + currentPlot?.plotID, + currentCensus?.plotCensusNumber, + currentQuadrat?.quadratID + ); try { - const response = await fetch(paginatedQuery, { method: 'GET' }); + const response = await fetch(paginatedQuery, { + method: + (filterModel.items && filterModel.items.length > 0) || (filterModel.quickFilterValues && filterModel.quickFilterValues.length > 0) ? 'POST' : 'GET', + headers: { 'Content-Type': 'application/json' }, + body: + (filterModel.items && filterModel.items.length > 0) || (filterModel.quickFilterValues && filterModel.quickFilterValues.length > 0) + ? JSON.stringify({ filterModel }) + : undefined + }); const data = await response.json(); if (!response.ok) throw new Error(data.message || 'Error fetching data'); - console.log('rows: ', data.output); setRows(data.output); setRowCount(data.totalCount); + setUsingQuery(data.finishedQuery); if (isNewRowAdded && pageToFetch === newLastPage) { - await handleAddNewRow(); + handleAddNewRow(); } } catch (error) { console.error('Error fetching data:', error); @@ -704,8 +979,9 @@ export default function IsolatedDataGridCommons(props: Readonly { - // If the row is newly added and is being canceled, skip the update if (newRow?.isNew && !newRow?.id) { - return oldRow; // Return the old row without making changes + return oldRow; } - setLoading(true, 'Processing changes...'); + setLoading(true); - // Handle new rows by confirming the save action with the user if (newRow.isNew || !newRow.id) { setPromiseArguments({ resolve: async (confirmedRow: GridRowModel) => { @@ -781,7 +1055,6 @@ export default function IsolatedDataGridCommons(props: Readonly { const firstEditableColumn = filteredColumns.find(col => col.editable); if (firstEditableColumn) { @@ -858,19 +1129,16 @@ export default function IsolatedDataGridCommons(props: Readonly String(row.id) === String(id)); if (row?.isNew) { - // Remove the new row from the rows state setRows(oldRows => oldRows.filter(row => row.id !== id)); - // Safely remove the row from rowModesModel setRowModesModel(prevModel => { const updatedModel = { ...prevModel }; - delete updatedModel[id]; // Remove the newly added row from rowModesModel + delete updatedModel[id]; return updatedModel; }); - setIsNewRowAdded(false); // Reset the flag indicating a new row was added + setIsNewRowAdded(false); } else { - // Revert the row to view mode if it's an existing row setRowModesModel(prevModel => ({ ...prevModel, [id]: { mode: GridRowModes.View, ignoreModifications: true } @@ -880,6 +1148,14 @@ export default function IsolatedDataGridCommons(props: Readonly ({ + ...prevFilterModel, + items: [...(incomingValues.items || [])], + quickFilterValues: [...(incomingValues.quickFilterValues || [])] + })); + } + const getEnhancedCellAction = useCallback( (type: string, icon: any, onClick: any) => ( @@ -931,16 +1207,10 @@ export default function IsolatedDataGridCommons(props: Readonly { - return [...gridColumns, getGridActionsColumn()]; + return [...applyFilterToColumns(gridColumns), getGridActionsColumn()]; }, [gridColumns, rowModesModel, getGridActionsColumn]); - const filteredColumns = useMemo(() => { - console.log('columns unfiltered: ', columns); - console.log('rows: ', rows); - console.log('filtered: ', filterColumns(rows, columns)); - if (gridType !== 'quadratpersonnel') return filterColumns(rows, columns); - else return columns; - }, [rows, columns]); + const filteredColumns = useMemo(() => (gridType !== 'quadratpersonnel' ? filterColumns(rows, columns) : columns), [rows, columns]); const handleCellDoubleClick: GridEventListener<'cellDoubleClick'> = params => { if (locked) return; @@ -950,23 +1220,12 @@ export default function IsolatedDataGridCommons(props: Readonly = (params, event) => { + const handleCellKeyDown: GridEventListener<'cellKeyDown'> = (_params, event) => { if (event.key === 'Enter' && !locked) { event.defaultMuiPrevented = true; - // console.log('params: ', params); - // setRowModesModel(prevModel => ({ - // ...prevModel, - // [params.id]: { mode: GridRowModes.Edit } - // })); } if (event.key === 'Escape') { event.defaultMuiPrevented = true; - // console.log('params: ', params); - // setRowModesModel(prevModel => ({ - // ...prevModel, - // [params.id]: { mode: GridRowModes.View, ignoreModifications: true } - // })); - // handleCancelClick(params.id, event); } }; @@ -986,12 +1245,11 @@ export default function IsolatedDataGridCommons(props: Readonly - Note: The Grid is filtered by your selected Plot and Plot ID { - // Create a promise to wait for state updates const waitForStateUpdates = async () => { return new Promise(resolve => { const checkUpdates = () => { if (rows.length > 0 && Object.keys(rowModesModel).length > 0) { - resolve(); // Resolve when states are updated + resolve(); } else { - setTimeout(checkUpdates, 50); // Retry every 50ms + setTimeout(checkUpdates, 50); } }; checkUpdates(); }); }; - - // Wait for rows and rowModesModel to update await waitForStateUpdates(); - - // Now call the actual processRowUpdate logic after the state has settled try { return await processRowUpdate(newRow, oldRow); } catch (error) { console.error('Error processing row update:', error); setSnackbar({ children: 'Error updating row', severity: 'error' }); - return Promise.reject(error); // Handle error if needed + return Promise.reject(error); } }} onProcessRowUpdateError={error => { @@ -1033,7 +1286,7 @@ export default function IsolatedDataGridCommons(props: Readonly setFilterModel(newFilterModel)} + ignoreDiacritics initialState={{ columns: { columnVisibilityModel: getColumnVisibilityModel(gridType) @@ -1056,7 +1310,9 @@ export default function IsolatedDataGridCommons(props: Readonly 'auto'} @@ -1068,12 +1324,7 @@ export default function IsolatedDataGridCommons(props: Readonly )} {isDialogOpen && promiseArguments && ( - + )} {isDeleteDialogOpen && ( void>(fn: T, delay: number): T { let timeoutId: ReturnType; @@ -71,30 +73,73 @@ function debounce void>(fn: T, delay: number): T { type EditToolbarProps = EditToolbarCustomProps & GridToolbarProps & ToolbarPropsOverrides; -const EditToolbar = ({ - handleAddNewRow, - handleRefresh, - handleExportAll, - handleExportErrors, - handleExportCSV, - handleRunValidations, - locked, - filterModel -}: EditToolbarProps) => { - const handleExportClick = async () => { - if (!handleExportAll) return; - const fullData = await handleExportAll(filterModel); - const blob = new Blob([JSON.stringify(fullData, null, 2)], { - type: 'application/json' +type VisibleFilter = 'valid' | 'errors' | 'pending'; + +interface ExtendedGridFilterModel extends GridFilterModel { + visible: VisibleFilter[]; +} + +const EditToolbar = (props: EditToolbarProps) => { + const { + handleAddNewRow, + handleExportErrors, + handleRefresh, + handleExportAll, + handleExportCSV, + handleQuickFilterChange, + locked, + filterModel, + dynamicButtons = [] + } = props; + if (!handleAddNewRow || !handleExportErrors || !handleRefresh || !handleQuickFilterChange || !handleExportAll) return <>; + const [inputValue, setInputValue] = useState(''); + const [isTyping, setIsTyping] = useState(false); + + const handleInputChange = (event: React.ChangeEvent) => { + setInputValue(event.target.value); + setIsTyping(true); + }; + + const handleKeyDown = (event: React.KeyboardEvent) => { + if (event.key === 'Enter') { + handleQuickFilterChange({ + ...filterModel, + items: filterModel?.items || [], + quickFilterValues: inputValue.split(' ') || [] + }); + setIsTyping(false); + } + }; + + const handleClearInput = () => { + setInputValue(''); + handleQuickFilterChange({ + ...filterModel, + items: filterModel?.items || [], + quickFilterValues: [] }); + setIsTyping(false); + }; + + useEffect(() => { + if (isTyping) { + const timeout = setTimeout(() => setIsTyping(false), 2000); + return () => clearTimeout(timeout); + } + }, [isTyping, inputValue]); + + function exportFilterModel() { + const jsonData = JSON.stringify(filterModel, null, 2); + const blob = new Blob([jsonData], { type: 'application/json' }); const url = URL.createObjectURL(blob); + const link = document.createElement('a'); link.href = url; - link.download = 'data.json'; - document.body.appendChild(link); + link.download = 'results.json'; link.click(); - document.body.removeChild(link); - }; + + URL.revokeObjectURL(url); + } const handleExportErrorsClick = async () => { if (!handleExportErrors) return; @@ -113,35 +158,95 @@ const EditToolbar = ({ return ( - - - - - - - {/**/} + + + + + + + + + + + + + + + + + } + > + Export... + + + await handleExportAll()}> + All data as JSON + + + All Data as Form + + + All errors as JSON + + + Filter Settings + + + + + + + {dynamicButtons.map((button: any, index: number) => ( + + ))} + ); }; -/** - * Renders custom UI components for measurement summary view. - * - * Handles state and logic for editing, saving, deleting rows, pagination, - * validation errors, printing, exporting, and more. - */ export default function MeasurementsCommons(props: Readonly) { const { addNewRowToGrid, @@ -161,39 +266,48 @@ export default function MeasurementsCommons(props: Readonly(null); // new state to track the new last page + const [newLastPage, setNewLastPage] = useState(null); const [isDialogOpen, setIsDialogOpen] = useState(false); const [pendingAction, setPendingAction] = useState({ actionType: '', actionId: null }); const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false); + const [isValidationModalOpen, setIsValidationModalOpen] = useState(false); const [promiseArguments, setPromiseArguments] = useState<{ resolve: (value: GridRowModel) => void; reject: (reason?: any) => void; newRow: GridRowModel; oldRow: GridRowModel; } | null>(null); - const [filterModel, setFilterModel] = useState({ - items: [] - }); + const [usingQuery, setUsingQuery] = useState(''); const [isSaveHighlighted, setIsSaveHighlighted] = useState(false); - // custom states -- msvdatagrid - const [validationErrors, setValidationErrors] = useState<{ - [key: number]: CMError; - }>({}); + const [validationErrors, setValidationErrors] = useState({}); const [showErrorRows, setShowErrorRows] = useState(true); const [showValidRows, setShowValidRows] = useState(true); - const [errorRowsForExport, setErrorRowsForExport] = useState([]); + const [showPendingRows, setShowPendingRows] = useState(true); + const [hidingEmpty, setHidingEmpty] = useState(true); + const [filterModel, setFilterModel] = useState({ + items: [], + quickFilterValues: [], + visible: [ + ...(showErrorRows ? (['errors'] as VisibleFilter[]) : []), + ...(showValidRows ? (['valid'] as VisibleFilter[]) : []), + ...(showPendingRows ? (['pending'] as VisibleFilter[]) : []) + ] + }); + const [sortModel, setSortModel] = useState([{ field: 'measurementDate', sort: 'asc' }]); + const [errorCount, setErrorCount] = useState(null); + const [validCount, setValidCount] = useState(null); + const [pendingCount, setPendingCount] = useState(null); // context pulls and definitions const currentSite = useSiteContext(); @@ -201,13 +315,22 @@ export default function MeasurementsCommons(props: Readonly { + setFilterModel(prevModel => ({ + ...prevModel, + visible: [ + ...(showErrorRows ? (['errors'] as VisibleFilter[]) : []), + ...(showValidRows ? (['valid'] as VisibleFilter[]) : []), + ...(showPendingRows ? (['pending'] as VisibleFilter[]) : []) + ] + })); + }, [showErrorRows, showValidRows, showPendingRows]); + const exportAllCSV = useCallback(async () => { const response = await fetch( `/api/formdownload/measurements/${currentSite?.schemaName ?? ''}/${currentPlot?.plotID ?? 0}/${currentCensus?.dateRanges[0].censusID ?? 0}`, @@ -266,43 +389,34 @@ export default function MeasurementsCommons(props: Readonly { const row = rows.find(row => rowId === row.id); - const error = validationErrors[row?.coreMeasurementID]; - if (!error) return false; - const errorFields = error.validationErrorIDs.flatMap(id => errorMapping[id.toString()] || []); - return errorFields.includes(colField); + if (!row || !row.coreMeasurementID || !validationErrors[row.coreMeasurementID]) { + return false; + } + return validationErrors[Number(row.coreMeasurementID)].errors.find(error => error.validationPairs.find(vp => vp.criterion === colField)); }; const rowHasError = (rowId: GridRowId) => { - if (!rows || rows.length === 0) return false; + const row = rows.find(row => rowId === row.id); + if (!row || !row.coreMeasurementID || !validationErrors[row.coreMeasurementID]) { + return false; // No errors for this row + } return gridColumns.some(column => cellHasError(column.field, rowId)); }; const fetchErrorRows = async () => { - if (!rows || rows.length === 0) return []; + if (!rows || rows.length === 0 || !validationErrors) return []; return rows.filter(row => rowHasError(row.id)); }; - const getRowErrorDescriptions = (rowId: GridRowId): string[] => { - const row = rows.find(row => rowId === row.id); - const error = validationErrors[row?.coreMeasurementID]; - return error.validationErrorIDs.map(id => { - const index = error.validationErrorIDs.indexOf(id); - return error.descriptions[index]; // Assumes that descriptions are stored in the CMError object - }); - }; - - const errorRowCount = useMemo(() => { - return rows.filter(row => rowHasError(row.id)).length; - }, [rows, gridColumns]); - const updateRow = async ( gridType: string, schemaName: string | undefined, newRow: GridRowModel, oldRow: GridRowModel, - setSnackbar: (value: { children: string; severity: 'error' | 'success' }) => void, + setSnackbar: Dispatch | null>>, setIsNewRowAdded: (value: boolean) => void, setShouldAddRowAfterFetch: (value: boolean) => void, fetchPaginatedData: (page: number) => Promise, @@ -337,6 +451,7 @@ export default function MeasurementsCommons(props: Readonly setTimeout(resolve, 1000)); + } catch (e: any) { + console.error(e); + } finally { + setLoading(false); + } + await new Promise(resolve => setTimeout(resolve, 1000)); // forced delay + await runFetchPaginated(); }; const performDeleteAction = async (id: GridRowId) => { @@ -450,6 +579,20 @@ export default function MeasurementsCommons(props: Readonly String(row.id) !== String(id))); + try { + setLoading(true, 'Refreshing Measurements Summary View...'); + const startTime = Date.now(); + const response = await fetch(`/api/refreshviews/measurementssummary/${currentSite?.schemaName ?? ''}`, { method: 'POST' }); + if (!response.ok) throw new Error('Measurements Summary View Refresh failure'); + const duration = (Date.now() - startTime) / 1000; + setLoading(true, `Completed in ${duration.toFixed(2)} seconds.`); + await new Promise(resolve => setTimeout(resolve, 1000)); + } catch (e: any) { + console.error(e); + } finally { + setLoading(false); + } + await new Promise(resolve => setTimeout(resolve, 1000)); // forced delay await fetchPaginatedData(paginationModel.page); } }; @@ -464,14 +607,6 @@ export default function MeasurementsCommons(props: Readonly) => { - setShowErrorRows(event.target.checked); - }; - - const handleShowValidRowsChange = (event: React.ChangeEvent) => { - setShowValidRows(event.target.checked); - }; - const handleAddNewRow = async () => { if (locked) { return; @@ -494,11 +629,6 @@ export default function MeasurementsCommons(props: Readonly { - console.error(message, error); - setSnackbar({ children: `Error: ${message}`, severity: 'error' }); - }; - const fetchPaginatedData = useCallback( debounce(async (pageToFetch: number) => { if (!currentSite || !currentPlot || !currentCensus) { @@ -506,77 +636,99 @@ export default function MeasurementsCommons(props: Readonly { if (currentPlot && currentCensus && paginationModel.page >= 0) { - fetchPaginatedData(paginationModel.page); + runFetchPaginated().catch(console.error); } - }, [currentPlot, currentCensus, paginationModel.page, sortModel, isNewRowAdded, fetchPaginatedData]); + }, [currentPlot, currentCensus, paginationModel, sortModel, isNewRowAdded, filterModel]); useEffect(() => { - if (errorRowCount > 0) { - setSnackbar({ - children: `${errorRowCount} row(s) with validation errors detected.`, - severity: 'warning' + console.log('updated rows object: ', rows); + }, [rows]); + + useEffect(() => { + async function getCounts() { + const query = `SELECT + SUM(CASE WHEN vft.IsValidated = TRUE THEN 1 ELSE 0 END) AS CountValid, + SUM(CASE WHEN vft.IsValidated = FALSE THEN 1 ELSE 0 END) AS CountErrors, + SUM(CASE WHEN vft.IsValidated IS NULL THEN 1 ELSE 0 END) AS CountPending + FROM ${currentSite?.schemaName ?? ''}.${gridType} vft + JOIN ${currentSite?.schemaName ?? ''}.census c ON vft.PlotID = c.PlotID AND vft.CensusID = c.CensusID + WHERE vft.PlotID = ${currentPlot?.plotID ?? 0} + AND c.PlotID = ${currentPlot?.plotID ?? 0} + AND c.PlotCensusNumber = ${currentCensus?.plotCensusNumber ?? 0}`; + const response = await fetch(`/api/runquery`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(query) }); + if (!response.ok) throw new Error('measurementscommons failure. runquery execution for errorRowCount failed.'); + const data = await response.json(); + return data[0]; } - }, [errorRowCount]); - const handleRefresh = useCallback(async () => { - setRefresh(true); - await fetch(`/api/refreshviews/measurementssummary/${currentSite?.schemaName}`, { method: 'POST' }); - setTimeout(async () => { - await fetchPaginatedData(paginationModel.page); - }, 2000); - setRefresh(false); - }, [fetchPaginatedData, paginationModel.page, refresh]); + getCounts().then(data => { + setValidCount(data.CountValid); + setErrorCount(data.CountErrors); + setPendingCount(data.CountPending); + const counts = [ + { count: data.CountErrors, message: `${data.CountErrors} row(s) with validation errors detected.`, severity: 'warning' }, + { count: data.CountPending, message: `${data.CountPending} row(s) pending validation.`, severity: 'info' }, + { count: data.CountValid, message: `${data.CountValid} row(s) passed validation.`, severity: 'success' } + ]; + const highestCount = counts.reduce((prev, current) => (current.count > prev.count ? current : prev)); + setSnackbar({ + children: highestCount.message, + severity: highestCount.severity as OverridableStringUnion | undefined + }); + }); + }, [rows, paginationModel]); const processRowUpdate = useCallback( (newRow: GridRowModel, oldRow: GridRowModel) => @@ -590,7 +742,7 @@ export default function MeasurementsCommons(props: Readonly { @@ -681,106 +833,146 @@ export default function MeasurementsCommons(props: Readonly { try { - const response = await fetch(`/api/validations/validationerrordisplay?schema=${currentSite?.schemaName ?? ''}`); + const response = await fetch( + `/api/validations/validationerrordisplay?schema=${currentSite?.schemaName ?? ''}&plotIDParam=${currentPlot?.plotID ?? ''}&censusPCNParam=${currentCensus?.plotCensusNumber ?? ''}` + ); if (!response.ok) { throw new Error('Failed to fetch validation errors'); } const data = await response.json(); - const errors: CMError[] = data?.failed ?? []; - const errorMap = Array.isArray(errors) - ? errors.reduce>((acc, error) => { - acc[error?.coreMeasurementID] = error; + const errorMap: ErrorMap = Array.isArray(data?.failed as CMError[]) + ? (data.failed as CMError[]).reduce>((acc, error) => { + if (error.coreMeasurementID) { + const errorDetailsMap = new Map(); + + (error.validationErrorIDs || []).forEach((id, index) => { + const descriptions = error.descriptions?.[index]?.split(';') || []; + const criteria = error.criteria?.[index]?.split(';') || []; + + // Ensure descriptions and criteria are paired correctly + const validationPairs = descriptions.map((description, i) => ({ + description, + criterion: criteria[i] ?? '' // Default to empty if criteria is missing + })); + + if (!errorDetailsMap.has(id)) { + errorDetailsMap.set(id, []); + } + + // Append validation pairs to the corresponding ID + errorDetailsMap.get(id)!.push(...validationPairs); + }); + + acc[error.coreMeasurementID] = { + coreMeasurementID: error.coreMeasurementID, + errors: Array.from(errorDetailsMap.entries()).map(([id, validationPairs]) => ({ + id, + validationPairs + })) + }; + } return acc; }, {}) : {}; - - // Only update state if there is a difference - if (JSON.stringify(validationErrors) !== JSON.stringify(errorMap)) { - setValidationErrors(errorMap); - } - return errorMap; // Return the errorMap if you need to log it outside + setValidationErrors(errorMap); } catch (error) { console.error('Error fetching validation errors:', error); } - }, [currentSite?.schemaName, fetchPaginatedData]); - - const fetchFullData = async () => { - setLoading(true, 'Fetching full dataset...'); - let partialQuery = ``; - if (currentPlot?.plotID) partialQuery += `/${currentPlot.plotID}`; - if (currentCensus?.plotCensusNumber) partialQuery += `/${currentCensus.plotCensusNumber}`; - if (currentQuadrat?.quadratID) partialQuery += `/${currentQuadrat.quadratID}`; - const fullDataQuery = `/api/fetchall/${gridType}` + partialQuery + `?schema=${currentSite?.schemaName}`; + }, [currentSite?.schemaName]); + const fetchFullData = useCallback(async () => { + setLoading(true); try { - const response = await fetch(fullDataQuery, { - method: 'GET', - headers: { 'Content-Type': 'application/json' } - // body: JSON.stringify(filterModel) - }); - if (!response.ok) throw new Error(response.statusText || 'Error fetching full data'); - return await response.json(); + const reworkedQuery = usingQuery + .replace(/\bSQL_CALC_FOUND_ROWS\b\s*/i, '') + .replace(/\bLIMIT\s+\d+\s*,\s*\d+/i, '') + .trim(); + + const results = await ( + await fetch(`/api/runquery`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(reworkedQuery) + }) + ).json(); + + const jsonData = JSON.stringify(results, null, 2); + const blob = new Blob([jsonData], { type: 'application/json' }); + const url = URL.createObjectURL(blob); + + const link = document.createElement('a'); + link.href = url; + link.download = 'results.json'; + link.click(); + + URL.revokeObjectURL(url); } catch (error) { console.error('Error fetching full data:', error); setSnackbar({ children: 'Error fetching full data', severity: 'error' }); - return []; } finally { setLoading(false); } - }; + }, [usingQuery, filterModel, currentPlot, currentCensus, currentQuadrat, currentSite, gridType, setLoading]); const handleExportErrors = async () => { const errorRows = await fetchErrorRows(); return errorRows.map(row => { - const errors = getRowErrorDescriptions(row.id); - return { ...row, errors }; + return { ...row, errors: validationErrors[Number(row.coreMeasurementID)].errors ?? [] }; }); }; // custom column formatting: - const validationStatusColumn: GridColDef = { - field: 'isValidated', - headerName: '', - headerAlign: 'center', - align: 'center', - width: 50, - renderCell: (params: GridCellParams) => { - console.log('val stat rendercell'); - console.log('val stat params: ', params); - const rowId = params.row.coreMeasurementID; - console.log('rowId located: ', rowId); - const validationError = validationErrors[Number(rowId)]; - console.log('searched for val error: ', validationError); - const isPendingValidation = rows.find(row => row.coreMeasurementID === rowId)?.isValidated && !validationError; - console.log('pending validation? ', isPendingValidation); - const isValidated = params.row.isValidated; - console.log('is validated?', isValidated); - - if (validationError) { - return ( - - - - ); - } else if (isPendingValidation) { - return ( - - - + const validationStatusColumn: GridColDef = useMemo( + () => ({ + field: 'isValidated', + headerName: '', + headerAlign: 'center', + align: 'center', + width: 50, + renderCell: (params: GridCellParams) => { + console.log('validation errors full: ', validationErrors); + console.log('row: ', params.row); + console.log( + 'row in rows: ', + rows.find(row => row.coreMeasurementID === params.row.coreMeasurementID) ); - } else if (isValidated) { - return ( - - - - ); - } else { - return null; + if (validationErrors[Number(params.row.coreMeasurementID)]) { + console.log('val error: ', validationErrors[Number(params.row.coreMeasurementID)]); + const validationStrings = + validationErrors[Number(params.row.coreMeasurementID)]?.errors.map(errorDetail => { + const pairsString = errorDetail.validationPairs + .map(pair => `(${pair.description} <--> ${pair.criterion})`) // Format each validation pair + .join(', '); // Combine all pairs for the errorDetail + + return `ID ${errorDetail.id}: ${pairsString}`; // Format the string for the ID + }) || []; + return ( + + + + ); + } else if (params.row.isValidated === null) { + return ( + + + + ); + } else if (params.row.isValidated) { + return ( + + + + ); + } else { + return null; + } } - } - }; + }), + [rows, validationErrors] + ); + const measurementDateColumn: GridColDef = { field: 'measurementDate', headerName: 'Date', @@ -796,80 +988,97 @@ export default function MeasurementsCommons(props: Readonly ), valueFormatter: value => { - // Check if the date is present and valid if (!value || !moment(value).utc().isValid()) { return ''; } - // Format the date as a dash-separated set of numbers return moment(value).utc().format('YYYY-MM-DD'); } }; + + const getCellErrorMessages = (colField: string, coreMeasurementID: number) => { + const error = validationErrors[coreMeasurementID].errors; + if (!error || !Array.isArray(error)) { + return ''; + } + return error.flatMap(errorDetail => errorDetail.validationPairs).find(vp => vp.criterion === colField)?.description || null; + }; + const columns = useMemo(() => { - console.log('test: '); const commonColumns = gridColumns.map(column => { - Object.keys(validationErrors).forEach(validationError => { - console.log('validationerror: ', validationError); - }); - return column; - // return { - // ...column, - // renderCell: (params: GridCellParams) => { - // const cellValue = params.value !== undefined ? params.value?.toString() : ''; - // console.log('cellValue', cellValue); - // const cellError = cellHasError(column.field, params.id) ? getCellErrorMessages(column.field, params.id) : ''; - // console.log('cellERror', cellError); - // return ( - // - // {cellError ? ( - // <> - // {cellValue} - // - // {cellError} - // - // - // ) : ( - // {cellValue} - // )} - // - // ); - // } - // }; + return { + ...column, + renderCell: (params: GridCellParams) => { + const value = typeof params.value === 'string' ? params.value : (params.value?.toString() ?? ''); + const formattedValue = !isNaN(Number(value)) && value.includes('.') && value.split('.')[1].length > 2 ? Number(value).toFixed(2) : value; + const rowError = rowHasError(params.id); + const cellError = cellHasError(column.field, params.id) ? getCellErrorMessages(column.field, Number(params.row.coreMeasurementID)) : ''; + + const isMeasurementField = column.field === 'measuredDBH' || column.field === 'measuredHOM'; + + const renderMeasurementDetails = () => ( + <> + + {column.field === 'measuredDBH' + ? params.row.measuredDBH + ? Number(params.row.measuredDBH).toFixed(2) + : 'null' + : params.row.measuredHOM + ? Number(params.row.measuredHOM).toFixed(2) + : 'null'} + + + {column.field === 'measuredDBH' && params.row.dbhUnits && params.row.measuredDBH !== null && params.row.dbhUnits} + {column.field === 'measuredHOM' && params.row.homUnits && params.row.measuredHOM !== null && params.row.homUnits} + + + ); + + return ( + + {isMeasurementField ? ( + {renderMeasurementDetails()} + ) : ( + {formattedValue} + )} + {cellError !== '' && ( + + {cellError} + + )} + + ); + } + }; }); if (locked) { return [validationStatusColumn, measurementDateColumn, ...commonColumns]; } - return [validationStatusColumn, measurementDateColumn, ...commonColumns, getGridActionsColumn()]; - }, [gridColumns, locked]); + return [validationStatusColumn, measurementDateColumn, ...applyFilterToColumns(commonColumns), getGridActionsColumn()]; + }, [MeasurementsSummaryViewGridColumns, locked, rows, validationErrors]); - const filteredColumns = useMemo(() => filterColumns(rows, columns), [rows, columns]); - - const visibleRows = useMemo(() => { - let filteredRows = rows; - if (!showValidRows) { - filteredRows = filteredRows.filter(row => rowHasError(row.id)); - } - if (!showErrorRows) { - filteredRows = filteredRows.filter(row => !rowHasError(row.id)); - } - return filteredRows; - }, [rows, showErrorRows, showValidRows]); + const filteredColumns = useMemo(() => { + if (hidingEmpty) return filterColumns(rows, columns); + return columns; + }, [rows, columns, hidingEmpty]); const getRowClassName = (params: any) => { const rowId = params.id; @@ -923,6 +1132,45 @@ export default function MeasurementsCommons(props: Readonly { + return { + ...prevFilterModel, + items: [...(incomingValues.items || [])], + quickFilterValues: [...(incomingValues.quickFilterValues || [])] + }; + }); + } + + async function handleCloseValidationModal() { + setIsValidationModalOpen(false); + try { + setLoading(true, 'Refreshing Measurements Summary View...'); + const startTime = Date.now(); + const response = await fetch(`/api/refreshviews/measurementssummary/${currentSite?.schemaName ?? ''}`, { method: 'POST' }); + if (!response.ok) throw new Error('Measurements Summary View Refresh failure'); + const duration = (Date.now() - startTime) / 1000; + setLoading(true, `Completed in ${duration.toFixed(2)} seconds.`); + await new Promise(resolve => setTimeout(resolve, 1000)); + } catch (e: any) { + console.error(e); + } finally { + setLoading(false); + } + await runFetchPaginated(); + } + + const testRows = [ + { + id: 1, + coreMeasurementID: 2770850, + measurementDate: '2016-02-25T05:00:00.000Z', + measuredDBH: null, + isValidated: false, + measuredHOM: '1.300000' + } + ]; + if (!currentSite || !currentPlot || !currentCensus) { redirect('/dashboard'); } else { @@ -942,18 +1190,26 @@ export default function MeasurementsCommons(props: Readonly - - Show rows with errors: ({errorRowCount}) + setShowErrorRows(event.target.checked)} /> + Show rows failing validation: ({errorCount}) + + + setShowValidRows(event.target.checked)} /> + Show rows passing validation: ({validCount}) - - Show rows without errors: ({rows.length - errorRowCount}) + setShowPendingRows(event.target.checked)} /> + Show rows pending validation: ({pendingCount}) + + + setHidingEmpty(event.target.checked)} /> + {hidingEmpty ? `Hiding Empty Columns` : `Hide Empty Columns`} { + setPaginationModel(newPaginationModel); + }} onProcessRowUpdateError={error => { console.error('Row update error:', error); setSnackbar({ @@ -973,16 +1231,22 @@ export default function MeasurementsCommons(props: Readonly { if (event.key === 'Enter') { - handleEnterKeyNavigation(params, event).then(r => { - console.log(r); - }); + handleEnterKeyNavigation(params, event).then(r => {}); } }} paginationModel={paginationModel} rowCount={rowCount} - pageSizeOptions={[paginationModel.pageSize]} + pageSizeOptions={[10, 25, 50, 100]} sortModel={sortModel} onSortModelChange={handleSortModelChange} + filterModel={filterModel} + onFilterModelChange={newFilterModel => { + setFilterModel(prevModel => ({ + ...prevModel, + ...newFilterModel + })); + }} + ignoreDiacritics initialState={{ columns: { columnVisibilityModel: getColumnVisibilityModel(gridType) @@ -995,10 +1259,13 @@ export default function MeasurementsCommons(props: Readonly setIsValidationModalOpen(true) }] } }} getRowHeight={() => 'auto'} @@ -1030,6 +1297,7 @@ export default function MeasurementsCommons(props: Readonly )} + {isValidationModalOpen && } ); } diff --git a/frontend/components/forms/autocompletemultiselect.tsx b/frontend/components/forms/autocompletemultiselect.tsx deleted file mode 100644 index f31f5a44..00000000 --- a/frontend/components/forms/autocompletemultiselect.tsx +++ /dev/null @@ -1,81 +0,0 @@ -'use client'; -import React, { useEffect, useState } from 'react'; -import Autocomplete from '@mui/material/Autocomplete'; -import TextField from '@mui/material/TextField'; -import CircularProgress from '@mui/material/CircularProgress'; -import { useSiteContext } from '@/app/contexts/userselectionprovider'; - -export interface AutocompleteMultiSelectProps { - initialValue: string[]; - onChange: (selected: string[]) => void; -} - -export const AutocompleteMultiSelect: React.FC = props => { - const { initialValue, onChange } = props; - const [open, setOpen] = useState(false); - const [options, setOptions] = useState([]); - const [inputValue, _setInputValue] = useState(''); - const loading = open && options.length === 0; - const currentSite = useSiteContext(); - if (!currentSite) throw new Error('Site must be selected!'); - - useEffect(() => { - let active = true; - - if (!loading) { - return undefined; - } - - (async () => { - const response = await fetch(`/api/formsearch/attributes?schema=${currentSite.schemaName}&searchfor=${encodeURIComponent(inputValue)}`); - const items = await response.json(); - - if (active) { - setOptions(items); - } - })(); - - return () => { - active = false; - }; - }, [loading, inputValue]); - - useEffect(() => { - if (!open) { - setOptions([]); - } - }, [open]); - - return ( - setOpen(true)} - onClose={() => setOpen(false)} - options={options} - getOptionLabel={option => option} - isOptionEqualToValue={(option, value) => option === value} - loading={loading} - value={initialValue} - onChange={(_event, newValue) => onChange(newValue)} - filterSelectedOptions - renderInput={params => ( - - {loading ? : null} - {params.InputProps.endAdornment} - - ) - }} - /> - )} - /> - ); -}; diff --git a/frontend/components/forms/censusacinputform.tsx b/frontend/components/forms/censusacinputform.tsx deleted file mode 100644 index 1c0fc53a..00000000 --- a/frontend/components/forms/censusacinputform.tsx +++ /dev/null @@ -1,457 +0,0 @@ -'use client'; -import React, { useState } from 'react'; -import { - DataGrid, - GridActionsCellItem, - GridColDef, - GridEventListener, - GridRenderCellParams, - GridRowEditStopReasons, - GridRowId, - GridRowModel, - GridRowModes, - GridRowModesModel, - GridRowsProp, - GridToolbarContainer, - GridToolbarProps, - ToolbarPropsOverrides -} from '@mui/x-data-grid'; -import { randomId } from '@mui/x-data-grid-generator'; -import AddIcon from '@mui/icons-material/Add'; -import SaveIcon from '@mui/icons-material/Save'; -import CancelIcon from '@mui/icons-material/Close'; -import EditIcon from '@mui/icons-material/Edit'; -import { DeleteIcon } from '@/components/icons'; -import { Box, Button } from '@mui/material'; -import AutocompleteFixedData from '@/components/forms/autocompletefixeddata'; -import { AutocompleteMultiSelect } from '@/components/forms/autocompletemultiselect'; -import Divider from '@mui/joy/Divider'; -import Typography from '@mui/joy/Typography'; -import { useOrgCensusContext, usePlotContext, useSiteContext } from '@/app/contexts/userselectionprovider'; -import { useSession } from 'next-auth/react'; -import UploadValidation from '@/components/uploadsystem/segments/uploadvalidation'; -import UploadUpdateValidations from '@/components/uploadsystem/segments/uploadupdatevalidations'; -import { ReviewStates } from '@/config/macros/uploadsystemmacros'; -import { DialogContent, DialogTitle, Modal, ModalDialog } from '@mui/joy'; -import { unitSelectionOptions } from '@/config/macros'; - -interface EditToolbarCustomProps { - handleAddNewRow?: () => void; - handleRefresh?: () => Promise; - locked?: boolean; -} - -type EditToolbarProps = EditToolbarCustomProps & GridToolbarProps & ToolbarPropsOverrides; - -function EditToolbar(props: Readonly) { - const { setRows, setRowModesModel } = props; - const handleClick = () => { - const id = randomId(); - setRows((oldRows: any) => [ - ...oldRows, - { - id, - date: new Date(), - personnel: '', - quadratName: '', - treeTag: '', - stemTag: '', - stemX: 0, - stemY: 0, - speciesCode: '', - dbh: 0, - hom: 0, - codes: [], // Initialize codes as an empty array - comments: '', - isNew: true - } - ]); - setRowModesModel((oldModel: any) => ({ - ...oldModel, - [id]: { mode: GridRowModes.Edit, fieldToFocus: 'quadratName' } - })); - }; - - return ( - - - - ); -} - -const CensusAutocompleteInputForm = () => { - /** - * "tag": "Trees.TreeTag", - * "stemtag": "Stems.StemTag", - * "spcode": "Species.SpeciesCode", - * "subquadrat": "Subquadrats.SubquadratName", - * "lx": "Stems.StemQuadX", - * "ly": "Stems.StemQuadY", - * "dbh": "CoreMeasurements.MeasuredDBH", - * "dbhunit": "CoreMeasurements.DBHUnit" - * "codes": "Attributes.Code", - * "hom": "CoreMeasurement.MeasuredHOM", - * "homunit": "CoreMeasurements.HOMUnit", - * "date": "CoreMeasurement.MeasurementDate", - */ - const initialRows: GridRowsProp = [ - { - id: 0, - stemTag: '', - treeTag: '', - speciesCode: '', - subquadratName: '', - stemX: 0, - stemY: 0, - date: new Date(), - dbh: 0, - dbhUnit: '', - hom: 0, - homUnit: '', - codes: [], // Initialize codes as an empty array - personnel: '', - comments: '' - } - ]; - const columns: GridColDef[] = [ - { - field: 'date', - headerName: 'Date', - type: 'date', - headerClassName: 'header', - maxWidth: 100, - flex: 1, - align: 'left', - editable: true, - valueGetter: (params: any) => { - if (!params.value) return null; - return new Date(params.value); - } - }, - { - field: 'personnel', - headerName: 'Personnel', - flex: 1, - align: 'right', - renderCell: (params: GridRenderCellParams) => ( - handlePersonnelChange(params.id, newValue)} /> - ) - }, - { - field: 'quadrat', - headerName: 'Quadrat', - flex: 1, - align: 'right', - renderCell: (params: GridRenderCellParams) => ( - handleQuadratChange(params.id, newValue)} /> - ) - }, - { - field: 'treeTag', - headerName: 'Tree Tag', - flex: 1, - align: 'right', - renderCell: (params: GridRenderCellParams) => ( - handleTreeTagChange(params.id, newValue)} /> - ) - }, - { - field: 'stemTag', - headerName: 'Stem Tag', - flex: 1, - align: 'right', - renderCell: (params: GridRenderCellParams) => ( - handleStemTagChange(params.id, newValue)} /> - ) - }, - { - field: 'speciesCode', - headerName: 'Species Code', - flex: 1, - align: 'right', - renderCell: (params: GridRenderCellParams) => ( - handleSpeciesCodeChange(params.id, newValue)} /> - ) - }, - { - field: 'dbh', - headerName: 'DBH', - headerClassName: 'header', - type: 'number', - editable: true, - maxWidth: 75, - flex: 1, - align: 'right' - }, - { - field: 'dbhUnit', - headerName: '<- Unit', - headerClassName: 'header', - flex: 1, - align: 'left', - editable: true, - type: 'singleSelect', - valueOptions: unitSelectionOptions - }, - { - field: 'hom', - headerName: 'HOM', - headerClassName: 'header', - type: 'number', - editable: true, - maxWidth: 75, - flex: 1, - align: 'right' - }, - { - field: 'homUnit', - headerName: '<- Unit', - headerClassName: 'header', - flex: 1, - align: 'left', - editable: true, - type: 'singleSelect', - valueOptions: unitSelectionOptions - }, - { - field: 'codes', - headerName: 'Codes', - width: 200, - flex: 1, - align: 'left', - renderCell: (params: GridRenderCellParams) => ( - handleCodesChange(params.id, newCodes)} /> - ) - }, - { - field: 'actions', - type: 'actions', - headerName: 'Actions', - maxWidth: 100, - cellClassName: 'actions', - flex: 1, - align: 'center', - getActions: ({ id }) => { - const isInEditMode = rowModesModel[id]?.mode === GridRowModes.Edit; - - if (isInEditMode) { - return [ - } - label="Save" - key="Save" - sx={{ - color: 'primary.main' - }} - onClick={handleSaveClick(id)} - />, - } label="Cancel" key="Cancel" className="textPrimary" onClick={handleCancelClick(id)} color="inherit" /> - ]; - } - - return [ - } label="Edit" key="Edit" className="textPrimary" onClick={handleEditClick(id)} color="inherit" />, - } label="Delete" key="Delete" onClick={handleDeleteClick(id)} color="inherit" /> - ]; - } - } - ]; - const [rows, setRows] = React.useState(initialRows); - const [rowModesModel, setRowModesModel] = React.useState({}); - // New state to track if the form is ready for submission - const [isFormComplete, setIsFormComplete] = useState(false); - - const [activeStep, setActiveStep] = useState('validation'); // 'validation', 'update', or 'summary' - const [validationResults, setValidationResults] = useState(null); - const [updateResults, setUpdateResults] = useState(null); - - const [reviewState, setReviewState] = useState(ReviewStates.UPLOAD_SQL); // placeholder - - const currentPlot = usePlotContext(); - const currentCensus = useOrgCensusContext(); - const currentSite = useSiteContext(); - - const { data: session } = useSession(); - const handleQuadratChange = (id: number | string, newValue: string) => { - setRows(rows.map(row => (row.id === id ? { ...row, quadratName: newValue } : row))); - }; - const handlePersonnelChange = (id: number | string, newValue: string) => { - setRows(rows.map(row => (row.id === id ? { ...row, personnel: newValue } : row))); - }; - const handleTreeTagChange = (id: number | string, newValue: string) => { - setRows(rows.map(row => (row.id === id ? { ...row, treeTag: newValue } : row))); - }; - const handleStemTagChange = (id: number | string, newValue: string) => { - setRows(rows.map(row => (row.id === id ? { ...row, stemTag: newValue } : row))); - }; - const handleSpeciesCodeChange = (id: number | string, newValue: string) => { - setRows(rows.map(row => (row.id === id ? { ...row, speciesCode: newValue } : row))); - }; - const handleCodesChange = (id: GridRowId, newCodes: string[]) => { - setRows(rows.map(row => (row.id === id ? { ...row, codes: newCodes } : row))); - }; - const handleRowEditStop: GridEventListener<'rowEditStop'> = (params, event) => { - if (params.reason === GridRowEditStopReasons.rowFocusOut) { - event.defaultMuiPrevented = true; - } - }; - - const handleEditClick = (id: GridRowId) => () => { - setRowModesModel({ ...rowModesModel, [id]: { mode: GridRowModes.Edit } }); - }; - - const handleSaveClick = (id: GridRowId) => () => { - setRowModesModel({ ...rowModesModel, [id]: { mode: GridRowModes.View } }); - }; - - const handleDeleteClick = (id: GridRowId) => () => { - setRows(rows.filter(row => row.id !== id)); - }; - - const handleCancelClick = (id: GridRowId) => () => { - setRowModesModel({ - ...rowModesModel, - [id]: { mode: GridRowModes.View, ignoreModifications: true } - }); - - const editedRow = rows.find(row => row.id === id); - if (editedRow!.isNew) { - setRows(rows.filter(row => row.id !== id)); - } - }; - - const processRowUpdate = (newRow: GridRowModel) => { - const updatedRow = { ...newRow, isNew: false }; - setRows(rows.map(row => (row.id === newRow.id ? updatedRow : row))); - return updatedRow; - }; - - const handleRowModesModelChange = (newRowModesModel: GridRowModesModel) => { - setRowModesModel(newRowModesModel); - }; - - // Function to check if all required fields in a row are filled - const checkFormCompletion = () => { - const allRowsComplete = rows.every(row => { - // Add checks for all required fields here - return row.date && row.personnel && row.quadratName && row.treeTag && row.stemTag && row.speciesCode && row.dbh && row.hom; - }); - setIsFormComplete(allRowsComplete); - }; - - // Update the form completion status whenever rows change - React.useEffect(() => { - checkFormCompletion(); - }, [rows]); - - // Function to format and submit data - const handleSubmit = async () => { - const formattedData = rows.reduce((acc, row) => { - // Map the row data to the FileRow structure - acc[row.id] = { - date: row.date.toISOString(), - personnel: row.personnel, - quadratName: row.quadratName, - treeTag: row.treeTag, - stemTag: row.stemTag, - speciesCode: row.speciesCode, - dbh: row.dbh.toString(), - hom: row.hom.toString(), - codes: row.codes.join(';') - }; - return acc; - }, {}); - - const fileRowSet = { censusData: formattedData }; // Assuming 'censusData' as the file name - - try { - // Add code to retrieve additional required parameters like schema, fileName, etc. - const response = await fetch( - `/api/sqlload?schema=${currentSite?.schemaName ?? ''}&fileName=censusData&plot=${currentPlot?.plotID}&census=${currentCensus?.dateRanges[0].censusID}&user=${session?.user?.name}&formType=measurements&uom=metric`, - { - method: 'POST', - headers: { - 'Content-Type': 'application/json' - }, - body: JSON.stringify(fileRowSet) - } - ); - - const responseData = await response.json(); // not gonna do anything with this, this output is intended for upload system. - // Handle the response - if (response.ok) { - setReviewState(ReviewStates.VALIDATE); - } else { - console.error('Error submitting form:', responseData); - // Handle submission error - } - } catch (error) { - console.error('Error submitting form:', error); - } - }; - // Render different components based on activeStep - let content; - switch (activeStep) { - case 'validation': - content = ; - break; - case 'update': - content = ; - break; - default: - content = null; - } - return ( - - - Plot Name: {currentPlot?.plotName ?? 'None'}, Census ID: {currentCensus?.dateRanges[0].censusID ?? '0'} - - - - - - 'dataGridCell'} - rowHeight={75} - rows={rows} - columns={columns} - autoHeight - checkboxSelection - disableRowSelectionOnClick - editMode="row" - rowModesModel={rowModesModel} - onRowModesModelChange={handleRowModesModelChange} - onRowEditStop={handleRowEditStop} - processRowUpdate={processRowUpdate} - slots={{ - toolbar: EditToolbar - }} - slotProps={{ - toolbar: { setRows, setRowModesModel } - }} - /> - - - Validation and Update Stages - - {reviewState === ReviewStates.VALIDATE && } - {reviewState === ReviewStates.UPDATE && } - - - - - ); -}; - -export default CensusAutocompleteInputForm; diff --git a/frontend/components/forms/censusinlinevalidationform.tsx b/frontend/components/forms/censusinlinevalidationform.tsx deleted file mode 100644 index 08cb5744..00000000 --- a/frontend/components/forms/censusinlinevalidationform.tsx +++ /dev/null @@ -1,446 +0,0 @@ -'use client'; -import React, { useEffect, useState } from 'react'; -import { - DataGrid, - GridActionsCellItem, - GridColDef, - GridRenderCellParams, - GridRowId, - GridRowModel, - GridRowModes, - GridRowModesModel, - GridRowParams, - GridRowsProp, - GridToolbarContainer, - GridToolbarProps, - GridValidRowModel -} from '@mui/x-data-grid'; -import { randomId } from '@mui/x-data-grid-generator'; -import AddIcon from '@mui/icons-material/Add'; -import SaveIcon from '@mui/icons-material/Save'; -import CancelIcon from '@mui/icons-material/Close'; -import EditIcon from '@mui/icons-material/Edit'; -import DeleteIcon from '@mui/icons-material/Delete'; -import { Box, Button } from '@mui/material'; -import Divider from '@mui/joy/Divider'; -import Typography from '@mui/joy/Typography'; -import { useOrgCensusContext, usePlotContext, useSiteContext } from '@/app/contexts/userselectionprovider'; -import { useSession } from 'next-auth/react'; -import { unitSelectionOptions } from '@/config/macros'; - -type EditToolbarProps = GridToolbarProps; - -function EditToolbar(props: EditToolbarProps) { - const { setRows, setRowModesModel } = props; - const handleClick = () => { - const id = randomId(); - setRows((oldRows: GridValidRowModel[]) => [ - ...oldRows, - { - id, - stemTag: '', - treeTag: '', - speciesCode: '', - subquadratName: '', - personnel: '', - stemX: 0, - stemY: 0, - date: new Date(), - dbh: 0, - dbhUnit: '', - hom: 0, - homUnit: '', - codes: [], // Initialize codes as an empty array - comments: '', - isNew: true - } - ]); - setRowModesModel((oldModel: GridRowModesModel) => ({ - ...oldModel, - [id]: { mode: GridRowModes.Edit, fieldToFocus: 'quadratName' } - })); - }; - - return ( - - - - ); -} - -const CensusAutocompleteInputForm = () => { - const initialRows: GridRowsProp = [ - { - id: 0, - stemTag: '', - treeTag: '', - speciesCode: '', - subquadratName: '', - stemX: 0, - stemY: 0, - date: new Date(), - dbh: 0, - dbhUnit: '', - hom: 0, - homUnit: '', - codes: '', // Initialize codes as an empty array - personnel: '', - comments: '' - } - ]; - - // Custom render function to show errors - const renderValidationCell = (params: GridRenderCellParams, fieldName: string) => { - const cellValue = params.value !== undefined ? params.value.toString() : ''; - const cellError = cellHasError(fieldName, params.id) ? getCellErrorMessages(fieldName, params.id) : ''; - console.log(`Rendering cell - Field: ${fieldName}, Error: ${cellError}`); - return ( - - {cellError ? ( - <> - {cellValue} - - {cellError} - - - ) : ( - {cellValue} - )} - - ); - }; - - const columns: GridColDef[] = [ - { - field: 'date', - headerName: 'Date', - type: 'date', - headerClassName: 'header', - maxWidth: 100, - flex: 1, - align: 'left', - editable: true, - valueGetter: (params: any) => { - return new Date(params.value) ?? new Date(); - } - }, - { - field: 'personnel', - headerName: 'Personnel', - flex: 1, - align: 'right', - editable: true, - renderCell: (params: GridRenderCellParams) => renderValidationCell(params, 'personnel') - }, - { - field: 'subquadratName', - headerName: 'Subquadrat', - flex: 1, - align: 'right', - editable: true, - renderCell: (params: GridRenderCellParams) => renderValidationCell(params, 'subquadratName') - }, - { - field: 'treeTag', - headerName: 'Tree Tag', - flex: 1, - align: 'right', - editable: true, - renderCell: (params: GridRenderCellParams) => renderValidationCell(params, 'treeTag') - }, - { - field: 'stemTag', - headerName: 'Stem Tag', - flex: 1, - align: 'right', - editable: true, - renderCell: (params: GridRenderCellParams) => renderValidationCell(params, 'stemTag') - }, - { - field: 'speciesCode', - headerName: 'SP Code', - flex: 1, - align: 'right', - editable: true, - renderCell: (params: GridRenderCellParams) => renderValidationCell(params, 'speciesCode') - }, - { - field: 'dbh', - headerName: 'DBH', - headerClassName: 'header', - type: 'number', - editable: true, - maxWidth: 75, - flex: 1, - align: 'right' - }, - { - field: 'dbhUnit', - headerName: '<- Unit', - headerClassName: 'header', - flex: 1, - align: 'left', - editable: true, - type: 'singleSelect', - valueOptions: unitSelectionOptions - }, - { - field: 'hom', - headerName: 'HOM', - headerClassName: 'header', - type: 'number', - editable: true, - maxWidth: 75, - flex: 1, - align: 'right' - }, - { - field: 'homUnit', - headerName: '<- Unit', - headerClassName: 'header', - flex: 1, - align: 'left', - editable: true, - type: 'singleSelect', - valueOptions: unitSelectionOptions - }, - { - field: 'codes', - headerName: 'Codes', - width: 200, - flex: 1, - align: 'left', - editable: true - }, - { - field: 'actions', - type: 'actions', - headerName: 'Actions', - maxWidth: 100, - cellClassName: 'actions', - flex: 1, - align: 'center', - getActions: ({ id }) => { - const isInEditMode = rowModesModel[id]?.mode === GridRowModes.Edit; - - if (isInEditMode) { - return [ - } - label="Save" - key="Save" - sx={{ - color: 'primary.main' - }} - onClick={handleSaveClick(id)} - />, - } label="Cancel" key="Cancel" className="textPrimary" onClick={handleCancelClick(id)} color="inherit" /> - ]; - } - - return [ - } label="Edit" key="Edit" className="textPrimary" onClick={handleEditClick(id)} color="inherit" />, - } label="Delete" key="Delete" onClick={handleDeleteClick(id)} color="inherit" /> - ]; - } - } - ]; - - const [rows, setRows] = useState(initialRows); - const [rowModesModel, setRowModesModel] = useState({}); - const [validationErrors, setValidationErrors] = useState<{ - [key: string]: string | null; - }>({}); - const [isFormComplete, setIsFormComplete] = useState(false); - - const currentPlot = usePlotContext(); - const currentCensus = useOrgCensusContext(); - const currentSite = useSiteContext(); - - const { data: session } = useSession(); - - const validateField = async (tableName: string, fieldName: string, value: string, rowId: GridRowId) => { - try { - const response = await fetch(`/api/formvalidation/${currentSite?.schemaName}/${tableName}/${fieldName}/${value}`, { - method: 'GET' - }); - if (!response.ok) { - const errorText = `${value}: Invalid ${fieldName}, not found in ${tableName}.`; - setValidationErrors(prev => ({ - ...prev, - [`${rowId}-${fieldName}`]: errorText - })); - return false; - } - setValidationErrors(prev => ({ - ...prev, - [`${rowId}-${fieldName}`]: null - })); - return true; - } catch (error) { - console.error(`Error validating ${fieldName}:`, error); - setValidationErrors(prev => ({ - ...prev, - [`${rowId}-${fieldName}`]: `Validation error for ${fieldName}.` - })); - return false; - } - }; - - const validateAllFields = async (row: GridValidRowModel) => { - const [firstName = '', lastName = ''] = row.personnel.split(' '); - const validations = [ - validateField('stems', 'StemTag', row.stemTag, row.id), - validateField('trees', 'TreeTag', row.treeTag, row.id), - validateField('species', 'SpeciesCode', row.speciesCode, row.id), - validateField('subquadrats', 'SubquadratName', row.quadratName, row.id), - validateField('personnel', 'FirstName', firstName, row.id), - validateField('personnel', 'LastName', lastName, row.id) - ]; - - const results = await Promise.all(validations); - const allValid = results.every(result => result); - if (!allValid) { - console.error('One or more fields are invalid.'); - } - return allValid; - }; - - const handleEditClick = (id: GridRowId) => () => { - setRowModesModel({ ...rowModesModel, [id]: { mode: GridRowModes.Edit } }); - }; - - const handleSaveClick = (id: GridRowId) => async () => { - const row = rows.find(row => row.id === id); - if (row) { - const isValid = await validateAllFields(row); - if (isValid) { - setRowModesModel({ - ...rowModesModel, - [id]: { mode: GridRowModes.View } - }); - } else { - console.error('Validation failed for row:', id); - } - } - }; - - const handleDeleteClick = (id: GridRowId) => () => { - setRows(rows.filter(row => row.id !== id)); - }; - - const handleCancelClick = (id: GridRowId) => () => { - setRowModesModel({ - ...rowModesModel, - [id]: { mode: GridRowModes.View, ignoreModifications: true } - }); - - const editedRow = rows.find(row => row.id === id); - if (editedRow!.isNew) { - setRows(rows.filter(row => row.id !== id)); - } - }; - - const processRowUpdate = (newRow: GridRowModel) => { - const updatedRow = { ...newRow, isNew: false }; - setRows(rows.map(row => (row.id === newRow.id ? updatedRow : row))); - return updatedRow; - }; - - const handleRowModesModelChange = (newRowModesModel: GridRowModesModel) => { - setRowModesModel(newRowModesModel); - }; - - const checkFormCompletion = () => { - const allRowsComplete = rows.every(row => { - return row.date && row.personnel && row.quadratName && row.treeTag && row.stemTag && row.speciesCode && row.dbh && row.hom; - }); - setIsFormComplete(allRowsComplete); - }; - - useEffect(() => { - checkFormCompletion(); - }, [rows]); - - // Prevent saving row on Enter key press or any other shortcut - const handleCellKeyDown = (params: any, event: { key: string; preventDefault: () => void }) => { - if (event.key === 'Enter') { - event.preventDefault(); - } - }; - - const rowHasError = (rowId: GridRowId) => { - return Object.keys(validationErrors).some(key => key.startsWith(rowId + '-') && validationErrors[key] != null); - }; - - const getRowClassName = (params: GridRowParams) => { - if (rowHasError(params.id)) return 'error-row'; - return ''; - }; - - const cellHasError = (fieldName: string, rowId: GridRowId) => { - return validationErrors[`${rowId}-${fieldName}`] != null; - }; - - const getCellErrorMessages = (fieldName: string, rowId: GridRowId) => { - return validationErrors[`${rowId}-${fieldName}`]; - }; - - return ( - - - Plot Name: {currentPlot?.plotName ?? 'None'}, Census ID: {currentCensus?.dateRanges[0].censusID ?? '0'} - - - {/* */} - - - 'dataGridCell'} - rows={rows} - columns={columns} - getRowHeight={() => 'auto'} - autoHeight - checkboxSelection - disableRowSelectionOnClick - editMode="row" - rowModesModel={rowModesModel} - onRowModesModelChange={handleRowModesModelChange} - processRowUpdate={processRowUpdate} - onCellKeyDown={handleCellKeyDown} - slots={{ - toolbar: EditToolbar - }} - slotProps={{ - toolbar: { setRows, setRowModesModel } // Ensure these methods are passed correctly - }} - /> - - ); -}; - -export default CensusAutocompleteInputForm; diff --git a/frontend/components/forms/personnelautocompletemultiselect.tsx b/frontend/components/forms/personnelautocompletemultiselect.tsx deleted file mode 100644 index f1d32728..00000000 --- a/frontend/components/forms/personnelautocompletemultiselect.tsx +++ /dev/null @@ -1,129 +0,0 @@ -'use client'; -import React, { useEffect, useState } from 'react'; -import Autocomplete from '@mui/material/Autocomplete'; -import TextField from '@mui/material/TextField'; -import CircularProgress from '@mui/material/CircularProgress'; -import { useSiteContext } from '@/app/contexts/userselectionprovider'; - -import { PersonnelRDS } from '@/config/sqlrdsdefinitions/personnel'; - -export interface PersonnelAutocompleteMultiSelectProps { - initialValue: PersonnelRDS[]; - onChange: (selected: PersonnelRDS[]) => void; - locked: boolean; -} - -export const PersonnelAutocompleteMultiSelect: React.FC = props => { - const { locked, onChange } = props; - const [open, setOpen] = useState(false); - const [options, setOptions] = useState([]); - const [inputValue, setInputValue] = useState(''); - const [tempSelectedPersonnel, setTempSelectedPersonnel] = useState([]); - const [timer, setTimer] = useState(null); - - const loading = open && options.length === 0; - const currentSite = useSiteContext(); - if (!currentSite) throw new Error('Site must be selected!'); - - // Function to refresh data - const refreshData = () => { - fetch(`/api/formsearch/personnelblock?schema=${currentSite.schemaName}&searchfor=${encodeURIComponent(inputValue)}`) - .then(response => response.json()) - .then(data => { - setOptions(data); - }) - .catch(error => { - console.error('Error fetching data:', error); - }); - }; - const handleConfirm = () => { - onChange(tempSelectedPersonnel); - }; - - useEffect(() => { - if (timer) clearTimeout(timer); - const newTimer = setTimeout(refreshData, 5000); // Refresh after 5 seconds of inactivity - setTimer(newTimer); - - return () => { - clearTimeout(newTimer); - }; - }, [inputValue]); - - useEffect(() => { - let active = true; - - if (!loading) { - return undefined; - } - - (async () => { - const response = await fetch(`/api/formsearch/personnelblock?schema=${currentSite.schemaName}&searchfor=${encodeURIComponent(inputValue)}`, { - method: 'GET' - }); - const items = await response.json(); - console.log(items); - if (active) { - setOptions(items); - } - })(); - - return () => { - active = false; - }; - }, [loading, inputValue]); - - useEffect(() => { - if (!open) { - setOptions([]); - } - }, [open]); - - useEffect(() => { - // Pre-load options with empty input value - refreshData(); - }, []); // This will run only once when the component mounts - - return ( - <> - setOpen(true)} - onClose={() => setOpen(false)} - options={options} - getOptionLabel={option => `${option.lastName}, ${option.firstName} | ${option.roleID}`} - isOptionEqualToValue={(option, value) => JSON.stringify(option) === JSON.stringify(value)} - loading={loading} - value={undefined} - disabled={locked} - onChange={(_event, newValue) => { - setTempSelectedPersonnel(newValue); - handleConfirm(); - setInputValue(''); - }} - inputValue={inputValue} - onInputChange={(_event, newInputValue) => setInputValue(newInputValue)} - filterSelectedOptions - renderInput={params => ( - - {loading ? : null} - {params.InputProps.endAdornment} - - ) - }} - /> - )} - /> - - ); -}; diff --git a/frontend/components/loginlogout.tsx b/frontend/components/loginlogout.tsx index 1199c727..3e0c627d 100644 --- a/frontend/components/loginlogout.tsx +++ b/frontend/components/loginlogout.tsx @@ -8,11 +8,9 @@ import LogoutRoundedIcon from '@mui/icons-material/LogoutRounded'; import LoginRoundedIcon from '@mui/icons-material/LoginRounded'; import CircularProgress from '@mui/joy/CircularProgress'; import { Skeleton } from '@mui/joy'; -import { useRouter } from 'next/navigation'; export const LoginLogout = () => { const { data: session, status } = useSession(); - const router = useRouter(); const handleRetryLogin = () => { signIn('azure-ad', { callbackUrl: '/dashboard' }, { prompt: 'login' }).catch((error: any) => { diff --git a/frontend/components/processors/processcensus.tsx b/frontend/components/processors/processcensus.tsx index ce6a8bb4..adb3b9d2 100644 --- a/frontend/components/processors/processcensus.tsx +++ b/frontend/components/processors/processcensus.tsx @@ -1,12 +1,12 @@ -import { runQuery, SpecialProcessingProps } from '@/components/processors/processormacros'; import moment from 'moment'; import { createError, fetchPrimaryKey, handleUpsert } from '@/config/utils'; import { SpeciesResult, StemResult, TreeResult } from '@/config/sqlrdsdefinitions/taxonomies'; import { QuadratResult } from '@/config/sqlrdsdefinitions/zones'; import { CMAttributesResult, CoreMeasurementsResult } from '@/config/sqlrdsdefinitions/core'; +import { SpecialProcessingProps } from '@/config/macros'; export async function processCensus(props: Readonly): Promise { - const { connection, rowData, schema, plotID, censusID } = props; + const { connectionManager, rowData, schema, plotID, censusID } = props; if (!plotID || !censusID) { console.error('Missing required parameters: plotID or censusID'); throw new Error('Process Census: Missing plotID or censusID'); @@ -14,29 +14,52 @@ export async function processCensus(props: Readonly): Pr const { tag, stemtag, spcode, quadrat, lx, ly, coordinateunit, dbh, dbhunit, hom, homunit, date, codes } = rowData; try { - await connection.beginTransaction(); // Fetch species - const speciesID = await fetchPrimaryKey(schema, 'species', { SpeciesCode: spcode }, connection, 'SpeciesID'); + const speciesID = await fetchPrimaryKey(schema, 'species', { SpeciesCode: spcode }, connectionManager, 'SpeciesID'); // Fetch quadrat - const quadratID = await fetchPrimaryKey(schema, 'quadrats', { QuadratName: quadrat, PlotID: plotID }, connection, 'QuadratID'); + const quadratID = await fetchPrimaryKey(schema, 'quadrats', { QuadratName: quadrat, PlotID: plotID }, connectionManager, 'QuadratID'); if (tag) { // Handle Tree Upsert - const treeID = await handleUpsert(connection, schema, 'trees', { TreeTag: tag, SpeciesID: speciesID }, 'TreeID'); + const { id: treeID, operation: treeOperation } = await handleUpsert( + connectionManager, + schema, + 'trees', + { + TreeTag: tag, + SpeciesID: speciesID + }, + 'TreeID' + ); + console.log('tree tag: ', tag, ' was ', treeOperation, ' on ID # ', treeID); if (stemtag || lx || ly) { + let stemStatus: 'new recruit' | 'multistem' | 'old tree'; // Handle Stem Upsert - const stemID = await handleUpsert( - connection, + const { id: stemID, operation: stemOperation } = await handleUpsert( + connectionManager, schema, 'stems', { StemTag: stemtag, TreeID: treeID, QuadratID: quadratID, LocalX: lx, LocalY: ly, CoordinateUnits: coordinateunit }, 'StemID' ); + console.log('stem tag: ', stemtag, ' was ', stemOperation, ' on ID # ', stemID); + + if (stemOperation === 'inserted') { + stemStatus = treeOperation === 'inserted' ? 'new recruit' : 'multistem'; + } else { + stemStatus = 'old tree'; + } + console.log('stem status: ', stemStatus); + + // Prepare additional fields for core measurements + const userDefinedFields = JSON.stringify({ + treestemstate: { stem: stemOperation, tree: treeOperation, status: stemStatus } + }); // Handle Core Measurement Upsert - const coreMeasurementID = await handleUpsert( - connection, + const { id: coreMeasurementID } = await handleUpsert( + connectionManager, schema, 'coremeasurements', { @@ -49,7 +72,7 @@ export async function processCensus(props: Readonly): Pr MeasuredHOM: hom ? parseFloat(hom) : null, HOMUnit: homunit, Description: null, - UserDefinedFields: null + UserDefinedFields: userDefinedFields // using this to track the operation on the tree and stem }, 'CoreMeasurementID' ); @@ -64,11 +87,11 @@ export async function processCensus(props: Readonly): Pr console.error('No valid attribute codes found:', codes); } else { for (const code of parsedCodes) { - const attributeRows = await runQuery(connection, `SELECT COUNT(*) as count FROM ${schema}.attributes WHERE Code = ?`, [code]); + const attributeRows = await connectionManager.executeQuery(`SELECT COUNT(*) as count FROM ${schema}.attributes WHERE Code = ?`, [code]); if (!attributeRows || attributeRows.length === 0 || !attributeRows[0].count) { throw createError(`Attribute code ${code} not found or query failed.`, { code }); } - await handleUpsert(connection, schema, 'cmattributes', { CoreMeasurementID: coreMeasurementID, Code: code }, 'CMAID'); + await handleUpsert(connectionManager, schema, 'cmattributes', { CoreMeasurementID: coreMeasurementID, Code: code }, 'CMAID'); } } } @@ -85,14 +108,12 @@ export async function processCensus(props: Readonly): Pr SET c.StartDate = m.FirstMeasurementDate, c.EndDate = m.LastMeasurementDate WHERE c.CensusID = ${censusID};`; - await runQuery(connection, combinedQuery); - await connection.commit(); + await connectionManager.executeQuery(combinedQuery); console.log('Upsert successful. CoreMeasurement ID generated:', coreMeasurementID); return coreMeasurementID; } } } catch (error: any) { - await connection.rollback(); console.error('Upsert failed:', error.message); throw error; } diff --git a/frontend/components/processors/processorhelperfunctions.tsx b/frontend/components/processors/processorhelperfunctions.tsx index 665432b8..9656aaeb 100644 --- a/frontend/components/processors/processorhelperfunctions.tsx +++ b/frontend/components/processors/processorhelperfunctions.tsx @@ -1,16 +1,15 @@ -import { PoolConnection } from 'mysql2/promise'; -import { fileMappings, getConn, InsertUpdateProcessingProps, runQuery } from '@/components/processors/processormacros'; import { processCensus } from '@/components/processors/processcensus'; import MapperFactory from '@/config/datamapper'; -import { SitesRDS, SitesResult } from '@/config/sqlrdsdefinitions/zones'; import { handleUpsert } from '@/config/utils'; import { AllTaxonomiesViewRDS, AllTaxonomiesViewResult } from '@/config/sqlrdsdefinitions/views'; +import ConnectionManager from '@/config/connectionmanager'; +import { fileMappings, InsertUpdateProcessingProps } from '@/config/macros'; // need to try integrating this into validation system: export async function insertOrUpdate(props: InsertUpdateProcessingProps): Promise { const { formType, schema, ...subProps } = props; - const { connection, rowData } = subProps; + const { connectionManager, rowData } = subProps; const mapping = fileMappings[formType]; if (!mapping) { throw new Error(`Mapping not found for file type: ${formType}`); @@ -43,13 +42,10 @@ export async function insertOrUpdate(props: InsertUpdateProcessingProps): Promis try { // Execute the query using the provided connection - await connection.beginTransaction(); - await connection.query(query, values); - await connection.commit(); + await connectionManager.executeQuery(query, values); } catch (error) { // Rollback the transaction in case of an error console.log(`INSERT OR UPDATE: error in query execution: ${error}. Rollback commencing and error rethrow: `); - await connection.rollback(); throw error; // Re-throw the error after rollback } } @@ -57,73 +53,6 @@ export async function insertOrUpdate(props: InsertUpdateProcessingProps): Promis } } -/** - * Retrieves all available sites from the database. - * - * @returns {Promise} An array of `SitesRDS` objects representing the available sites. - * @throws {Error} If there is an error connecting to the database or executing the query. - */ -export async function getAllSchemas(): Promise { - const connection: PoolConnection | null = await getConn(); - try { - // Query to get sites - const sitesQuery = ` - SELECT * - FROM catalog.sites - `; - const sitesParams: any[] | undefined = []; - const sitesResults = await runQuery(connection, sitesQuery, sitesParams); - - return MapperFactory.getMapper('sites').mapData(sitesResults); - } catch (error: any) { - throw new Error(error); - } finally { - if (connection) connection.release(); - } -} - -/** - * Retrieves the list of sites that the user with the given email address is allowed to access. - * - * @param email - The email address of the user. - * @returns {Promise} An array of `SitesRDS` objects representing the sites the user is allowed to access. - * @throws {Error} If there is an error connecting to the database or executing the query, or if the user is not found. - */ -export async function getAllowedSchemas(email: string): Promise { - const connection: PoolConnection | null = await getConn(); - try { - // Query to get user ID - const userQuery = ` - SELECT UserID - FROM catalog.users - WHERE Email = ? - `; - const userParams = [email]; - const userResults = await runQuery(connection, userQuery, userParams); - - if (userResults.length === 0) { - throw new Error('User not found'); - } - const userID = userResults[0].UserID; - - // Query to get sites - const sitesQuery = ` - SELECT s.* - FROM catalog.sites AS s - JOIN catalog.usersiterelations AS usr ON s.SiteID = usr.SiteID - WHERE usr.UserID = ? - `; - const sitesParams = [userID]; - const sitesResults = await runQuery(connection, sitesQuery, sitesParams); - - return MapperFactory.getMapper('sites').mapData(sitesResults); - } catch (error: any) { - throw new Error(error); - } finally { - if (connection) connection.release(); - } -} - type FieldList = string[]; interface UpdateQueryConfig { @@ -137,7 +66,7 @@ interface UpdateQueryConfig { } export async function handleUpsertForSlices( - connection: PoolConnection, + connectionManager: ConnectionManager, schema: string, newRow: Partial, config: UpdateQueryConfig @@ -180,7 +109,7 @@ export async function handleUpsertForSlices( } // Perform the upsert and store the resulting ID - insertedIds[sliceKey] = await handleUpsert(connection, schema, sliceKey, rowData, primaryKey as keyof Result); + insertedIds[sliceKey] = (await handleUpsert(connectionManager, schema, sliceKey, rowData, primaryKey as keyof Result)).id; } return insertedIds; @@ -200,7 +129,7 @@ function getPreviousSlice(currentSlice: string, slices: { [key: string]: any }): // Helper function to get the immediate previous slice based on dependencies export async function handleDeleteForSlices( - connection: PoolConnection, + connectionManager: ConnectionManager, schema: string, rowData: Partial, config: UpdateQueryConfig @@ -238,7 +167,7 @@ export async function handleDeleteForSlices( `; try { console.log('Deleting related rows from trees for SpeciesID:', primaryKeyValue); - await runQuery(connection, deleteFromRelatedTableQuery, [primaryKeyValue]); + await connectionManager.executeQuery(deleteFromRelatedTableQuery, [primaryKeyValue]); } catch (error) { console.error(`Error deleting related rows from trees for SpeciesID ${primaryKeyValue}:`, error); throw new Error(`Failed to delete related rows from trees for SpeciesID ${primaryKeyValue}.`); @@ -256,7 +185,7 @@ export async function handleDeleteForSlices( console.log('Delete query:', deleteQuery); // Use runQuery helper for executing the delete query - await runQuery(connection, deleteQuery, [primaryKeyValue]); + await connectionManager.executeQuery(deleteQuery, [primaryKeyValue]); } catch (error) { console.error(`Error during deletion in ${sliceKey}:`, error); throw new Error(`Failed to delete from ${sliceKey}. Please check the logs for details.`); @@ -367,11 +296,11 @@ export async function runValidation( minHOM?: number | null; maxHOM?: number | null; } = {} -): Promise<{ TotalRows: number; Message: string }> { - const conn = await getConn(); +): Promise { + const connectionManager = new ConnectionManager(); try { - await conn.beginTransaction(); + await connectionManager.beginTransaction(); // Dynamically replace SQL variables with actual TypeScript input values const formattedCursorQuery = cursorQuery @@ -381,8 +310,9 @@ export async function runValidation( .replace(/@maxDBH/g, params.maxDBH !== null && params.maxDBH !== undefined ? params.maxDBH.toString() : 'NULL') .replace(/@minHOM/g, params.minHOM !== null && params.minHOM !== undefined ? params.minHOM.toString() : 'NULL') .replace(/@maxHOM/g, params.maxHOM !== null && params.maxHOM !== undefined ? params.maxHOM.toString() : 'NULL') + .replace(/@validationProcedureID/g, validationProcedureID.toString()) .replace(/cmattributes/g, 'TEMP_CMATTRIBUTES_PLACEHOLDER') - .replace(/coremeasurements/g, `${schema}.coremeasurements`) // Fully qualify table names + .replace(/coremeasurements/g, `${schema}.coremeasurements`) .replace(/stems/g, `${schema}.stems`) .replace(/trees/g, `${schema}.trees`) .replace(/quadrats/g, `${schema}.quadrats`) @@ -423,7 +353,7 @@ export async function runValidation( AND (${params.p_PlotID !== null ? `q.PlotID = ${params.p_PlotID}` : 'TRUE'}) LIMIT 1; `; - const speciesLimits = await runQuery(conn, speciesLimitsQuery); + const speciesLimits = await connectionManager.executeQuery(speciesLimitsQuery); if (speciesLimits.length > 0) { // If any species-specific limits were fetched, update the variables @@ -442,85 +372,85 @@ export async function runValidation( .replace(/@maxHOM/g, params.maxHOM !== null && params.maxHOM !== undefined ? params.maxHOM.toString() : 'NULL'); // Execute the cursor query to get the rows that need validation - const cursorResults = await runQuery(conn, reformattedCursorQuery); - - if (cursorResults.length > 0) { - const insertErrorQuery = ` - INSERT INTO ${schema}.cmverrors (CoreMeasurementID, ValidationErrorID) - SELECT ?, ? - FROM DUAL - WHERE NOT EXISTS ( - SELECT 1 - FROM ${schema}.cmverrors - WHERE CoreMeasurementID = ? AND ValidationErrorID = ? - ); - `; - - // Insert errors for all rows that matched the validation condition - for (const row of cursorResults) { - await runQuery(conn, insertErrorQuery, [row.CoreMeasurementID, validationProcedureID, row.CoreMeasurementID, validationProcedureID]); - } - } - - await conn.commit(); - return { - TotalRows: cursorResults.length, - Message: `Validation completed successfully. Total rows processed: ${cursorResults.length}` - }; + console.log('running validation: ', validationProcedureName); + console.log('running query: ', reformattedCursorQuery); + await connectionManager.executeQuery(reformattedCursorQuery); + return true; } catch (error: any) { - await conn.rollback(); - console.error(`Error during ${validationProcedureName} validation:`, error.message); - throw new Error(`${validationProcedureName} validation failed. Please check the logs for more details.`); + await connectionManager.rollbackTransaction(); + console.error(`Error during ${validationProcedureName} or ${validationProcedureID} validation:`, error.message); + return false; } finally { - if (conn) conn.release(); + await connectionManager.closeConnection(); } } export async function updateValidatedRows(schema: string, params: { p_CensusID?: number | null; p_PlotID?: number | null }): Promise { - const conn = await getConn(); - const setVariables = `SET @p_CensusID = ?, @p_PlotID = ?;`; + const connectionManager = new ConnectionManager(); const tempTable = `CREATE TEMPORARY TABLE UpdatedRows (CoreMeasurementID INT);`; + const insertTemp = ` INSERT INTO UpdatedRows (CoreMeasurementID) SELECT cm.CoreMeasurementID FROM ${schema}.coremeasurements cm - LEFT JOIN ${schema}.cmverrors cme ON cm.CoreMeasurementID = cme.CoreMeasurementID JOIN ${schema}.census c ON cm.CensusID = c.CensusID WHERE cm.IsValidated IS NULL - AND (@p_CensusID IS NULL OR c.CensusID = @p_CensusID) - AND (@p_PlotID IS NULL OR c.PlotID = @p_PlotID);`; - const query = ` + AND (${params.p_CensusID} IS NULL OR c.CensusID = ${params.p_CensusID}) + AND (${params.p_PlotID} IS NULL OR c.PlotID = ${params.p_PlotID}); + `; + + const updateValidation = ` UPDATE ${schema}.coremeasurements cm - LEFT JOIN ${schema}.cmverrors cme ON cm.CoreMeasurementID = cme.CoreMeasurementID - JOIN ${schema}.census c ON cm.CensusID = c.CensusID - SET cm.IsValidated = CASE - WHEN cme.CMVErrorID IS NULL THEN TRUE - WHEN cme.CMVErrorID IS NOT NULL THEN FALSE - ELSE cm.IsValidated - END + SET cm.IsValidated = ( + CASE + WHEN NOT EXISTS ( + SELECT 1 + FROM ${schema}.cmverrors cme + WHERE cme.CoreMeasurementID = cm.CoreMeasurementID + ) THEN TRUE -- No validation errors exist + ELSE FALSE -- Validation errors exist + END + ) WHERE cm.IsValidated IS NULL - AND cm.CoreMeasurementID IN (SELECT CoreMeasurementID FROM UpdatedRows);`; + AND cm.CoreMeasurementID IN (SELECT CoreMeasurementID FROM UpdatedRows); + `; + const getUpdatedRows = ` SELECT cm.* FROM ${schema}.coremeasurements cm - JOIN UpdatedRows ur ON cm.CoreMeasurementID = ur.CoreMeasurementID;`; + WHERE cm.CoreMeasurementID IN (SELECT CoreMeasurementID FROM UpdatedRows); + `; + const dropTemp = `DROP TEMPORARY TABLE IF EXISTS UpdatedRows;`; + try { - await conn.beginTransaction(); - await runQuery(conn, dropTemp); // just in case - await runQuery(conn, setVariables, [params.p_CensusID || null, params.p_PlotID || null]); - await runQuery(conn, tempTable); - await runQuery(conn, insertTemp); - await runQuery(conn, query); - const results = await runQuery(conn, getUpdatedRows); - await runQuery(conn, dropTemp); - await conn.commit(); + // Begin transaction + await connectionManager.beginTransaction(); + + // Ensure any leftover temporary table is cleared + await connectionManager.executeQuery(dropTemp); + + // Create temporary table and populate it + await connectionManager.executeQuery(tempTable); + await connectionManager.executeQuery(insertTemp); + + // Update validation states + await connectionManager.executeQuery(updateValidation); + + // Fetch and return the updated rows + const results = await connectionManager.executeQuery(getUpdatedRows); + + // Clean up temporary table + await connectionManager.executeQuery(dropTemp); + return MapperFactory.getMapper('coremeasurements').mapData(results); } catch (error: any) { - await conn.rollback(); + // Roll back on error + await connectionManager.rollbackTransaction(); console.error(`Error during updateValidatedRows:`, error.message); - throw new Error(`updateValidatedRows failed. Please check the logs for more details.`); + throw new Error(`updateValidatedRows failed for validation: Please check the logs for more details.`); } finally { - if (conn) conn.release(); + // Close the connection + await connectionManager.closeConnection(); } } diff --git a/frontend/components/processors/processormacros.tsx b/frontend/components/processors/processormacros.tsx index d1d3b5d7..701c474b 100644 --- a/frontend/components/processors/processormacros.tsx +++ b/frontend/components/processors/processormacros.tsx @@ -1,102 +1,6 @@ import { PoolConnection, PoolOptions } from 'mysql2/promise'; -import { FileRow } from '@/config/macros/formdetails'; -import { processSpecies } from '@/components/processors/processspecies'; -import { processCensus } from '@/components/processors/processcensus'; import { PoolMonitor } from '@/config/poolmonitor'; -import { processPersonnel } from '@/components/processors/processpersonnel'; -import { processQuadrats } from '@/components/processors/processquadrats'; -export interface SpecialProcessingProps { - connection: PoolConnection; - rowData: FileRow; - schema: string; - plotID?: number; - censusID?: number; - quadratID?: number; - fullName?: string; -} - -export interface InsertUpdateProcessingProps extends SpecialProcessingProps { - formType: string; -} - -export type FileMapping = { - tableName: string; - columnMappings: { [fileColumn: string]: string }; - specialProcessing?: (props: Readonly) => Promise; -}; - -// Define the mappings for each file type -export const fileMappings: Record = { - attributes: { - tableName: 'Attributes', - columnMappings: { - code: 'Code', - description: 'Description', - status: 'Status' - } - }, - personnel: { - tableName: 'Personnel', - columnMappings: { - firstname: 'FirstName', - lastname: 'LastName', - role: 'Role' - }, - specialProcessing: processPersonnel - }, - species: { - tableName: '', - columnMappings: { - spcode: 'Species.SpeciesCode', - family: 'Family.Family', - genus: 'Genus.GenusName', - species: 'Species.SpeciesName', - subspecies: 'Species.SubspeciesName', // optional - IDLevel: 'Species.IDLevel', - authority: 'Species.Authority', - subauthority: 'Species.SubspeciesAuthority' // optional - }, - specialProcessing: processSpecies - }, - quadrats: { - tableName: 'quadrats', - // "quadrats": [{label: "quadrat"}, {label: "startx"}, {label: "starty"}, {label: "dimx"}, {label: "dimy"}, {label: "unit"}, {label: "quadratshape"}], - columnMappings: { - quadrat: 'QuadratName', - plotID: 'PlotID', - startx: 'StartX', - starty: 'StartY', - coordinateunit: 'CoordinateUnits', - dimx: 'DimensionX', - dimy: 'DimensionY', - dimensionunit: 'DimensionUnits', - quadratshape: 'QuadratShape' - }, - specialProcessing: processQuadrats - }, - // "subquadrats": "subquadrat, quadrat, dimx, dimy, xindex, yindex, unit, orderindex", - subquadrats: { - tableName: 'subquadrats', - columnMappings: { - subquadrat: 'SubquadratName', - quadrat: 'QuadratID', - plotID: 'PlotID', - censusID: 'CensusID', - dimx: 'DimensionX', - dimy: 'DimensionY', - xindex: 'X', - yindex: 'Y', - unit: 'Unit', - orderindex: 'Ordering' - } - }, - measurements: { - tableName: '', // Multiple tables involved - columnMappings: {}, - specialProcessing: processCensus - } -}; const sqlConfig: PoolOptions = { user: process.env.AZURE_SQL_USER, password: process.env.AZURE_SQL_PASSWORD, @@ -119,7 +23,7 @@ export async function getSqlConnection(tries: number): Promise { // Check if the pool is closed and reinitialize if necessary if (poolMonitor.isPoolClosed()) { console.log('Connection pool is closed. Reinitializing...'); - poolMonitor.reinitializePool(); + await poolMonitor.reinitializePool(); } const connection = await poolMonitor.getConnection(); @@ -181,98 +85,3 @@ export async function runQuery(connection: PoolConnection, query: string, params throw error; } } - -export function getCatalogSchema() { - const catalogSchema = process.env.AZURE_SQL_CATALOG_SCHEMA; - if (!catalogSchema) throw new Error('Environmental variable extraction for catalog schema failed'); - return catalogSchema; -} - -export type ValidationResponse = { - totalRows: number; - failedRows: number; - message: string; - failedCoreMeasurementIDs?: number[]; -}; -export type UpdateValidationResponse = { - rowsValidated: any; -}; - -export interface QueryConfig { - schema: string; - table: string; - joins?: { - table: string; - alias: string; - on: string; - }[]; - conditionals?: string; - pagination: { - page: number; - pageSize: number; - }; - extraParams?: any[]; -} - -export function buildPaginatedQuery(config: QueryConfig): { - query: string; - params: any[]; -} { - const { schema, table, joins, conditionals, pagination, extraParams } = config; - const { page, pageSize } = pagination; - const startRow = page * pageSize; - const queryParams = extraParams || []; - - // Establish an alias for the primary table for consistency in joins and selections - const tableAlias = table[0].toLowerCase(); // Simple default alias based on first letter of table name - - // Build the base query with possible joins - let query = `SELECT SQL_CALC_FOUND_ROWS ${tableAlias}.* FROM ${schema}.${table} AS ${tableAlias}`; - if (joins) { - joins.forEach(join => { - query += ` LEFT JOIN ${schema}.${join.table} AS ${join.alias} ON ${join.on}`; - }); - } - - if (conditionals) { - query += ` WHERE ${conditionals}`; - } - - // Add LIMIT clause - query += ` LIMIT ?, ?`; - queryParams.push(startRow, pageSize); // Ensure these are the last parameters added - - return { query, params: queryParams }; -} - -// Function to close all active connections -async function closeConnections() { - console.log('Closing all active connections...'); - await poolMonitor.closeAllConnections(); - console.log('All connections closed.'); -} - -// Function to handle graceful shutdown -async function gracefulShutdown() { - console.log('Initiating graceful shutdown...'); - try { - await closeConnections(); - console.log('Graceful shutdown complete.'); - process.exit(0); - } catch (error) { - console.error('Error during graceful shutdown:', error); - process.exit(1); - } -} - -// Capture SIGINT signal (triggered by ctrl+c) -process.on('SIGINT', async () => { - console.log('SIGINT signal received.'); - await gracefulShutdown(); -}); - -// Capture SIGTERM signal (triggered by process kill) -process.on('SIGTERM', async () => { - console.log('SIGTERM signal received.'); - await gracefulShutdown(); -}); diff --git a/frontend/components/processors/processpersonnel.tsx b/frontend/components/processors/processpersonnel.tsx index 9d97dac1..3bed3c99 100644 --- a/frontend/components/processors/processpersonnel.tsx +++ b/frontend/components/processors/processpersonnel.tsx @@ -1,17 +1,15 @@ import { createError, createInsertOrUpdateQuery, createSelectQuery } from '@/config/utils'; -import { runQuery, SpecialProcessingProps } from '@/components/processors/processormacros'; import { PersonnelResult, RoleResult } from '@/config/sqlrdsdefinitions/personnel'; +import { SpecialProcessingProps } from '@/config/macros'; export async function processPersonnel(props: Readonly) { - const { connection, rowData, schema, censusID } = props; + const { connectionManager, rowData, schema, censusID } = props; if (!censusID) throw createError('CensusID missing', { censusID }); if (!rowData.role) throw createError('Row data does not contain a role property', { rowData }); const { firstname, lastname, role, roledescription } = rowData; try { - await connection.beginTransaction(); - // Normalize the role name const normalizedRole = role .toLowerCase() @@ -22,7 +20,7 @@ export async function processPersonnel(props: Readonly) // Handle Role insertion/updation const roleQuery = createSelectQuery(schema, 'roles', { RoleName: normalizedRole }); console.log('role query: ', roleQuery); - const existingRoles = await runQuery(connection, roleQuery, [normalizedRole]); + const existingRoles = await connectionManager.executeQuery(roleQuery, [normalizedRole]); console.log('existing roles: ', existingRoles); let roleID; @@ -33,7 +31,7 @@ export async function processPersonnel(props: Readonly) console.log('existing role id: ', roleID); const updateRoleQuery = `UPDATE \`${schema}\`.\`roles\` SET RoleDescription = ? WHERE RoleID = ?`; console.log('update role query: ', updateRoleQuery); - await runQuery(connection, updateRoleQuery, [roledescription, roleID]); + await connectionManager.executeQuery(updateRoleQuery, [roledescription, roleID]); console.log('Role updated with description:', roledescription); } else { // If the role does not exist, insert a new role @@ -41,7 +39,7 @@ export async function processPersonnel(props: Readonly) RoleName: normalizedRole, RoleDescription: roledescription }); - const insertResult = await runQuery(connection, insertRoleQuery, [normalizedRole, roledescription]); + const insertResult = await connectionManager.executeQuery(insertRoleQuery, [normalizedRole, roledescription]); roleID = insertResult.insertId; console.log('New role inserted with RoleID:', roleID); } @@ -55,28 +53,25 @@ export async function processPersonnel(props: Readonly) }; const personnelQuery = createSelectQuery(schema, 'personnel', personnelData); - const existingPersonnel = await runQuery(connection, personnelQuery, Object.values(personnelData)); + const existingPersonnel = await connectionManager.executeQuery(personnelQuery, Object.values(personnelData)); let personnelID; if (existingPersonnel.length > 0) { // If personnel exists, update the row personnelID = existingPersonnel[0].PersonnelID; const updatePersonnelQuery = createInsertOrUpdateQuery(schema, 'personnel', personnelData); - await runQuery(connection, updatePersonnelQuery, Object.values(personnelData)); + await connectionManager.executeQuery(updatePersonnelQuery, Object.values(personnelData)); console.log('Personnel updated:', personnelID); } else { // Insert new personnel record const insertPersonnelQuery = createInsertOrUpdateQuery(schema, 'personnel', personnelData); - const insertResult = await runQuery(connection, insertPersonnelQuery, Object.values(personnelData)); + const insertResult = await connectionManager.executeQuery(insertPersonnelQuery, Object.values(personnelData)); personnelID = insertResult.insertId; console.log('New personnel inserted with PersonnelID:', personnelID); } - - await connection.commit(); console.log('Upsert successful. Personnel ID:', personnelID); return personnelID; } catch (error: any) { - await connection.rollback(); console.error('Upsert failed:', error.message); throw createError('Upsert failed', { error }); } diff --git a/frontend/components/processors/processquadrats.tsx b/frontend/components/processors/processquadrats.tsx index ef2bd7ae..a43780b8 100644 --- a/frontend/components/processors/processquadrats.tsx +++ b/frontend/components/processors/processquadrats.tsx @@ -1,15 +1,14 @@ -import { SpecialProcessingProps } from '@/components/processors/processormacros'; import { createError, handleUpsert } from '@/config/utils'; import { CensusQuadratResult, QuadratResult } from '@/config/sqlrdsdefinitions/zones'; +import { SpecialProcessingProps } from '@/config/macros'; export async function processQuadrats(props: Readonly) { - const { connection, rowData, schema, plotID, censusID } = props; + const { connectionManager, rowData, schema, plotID, censusID } = props; if (!censusID || !plotID) throw createError('CensusID missing', { censusID }); const { quadrat, startx, starty, coordinateunit, dimx, dimy, dimensionunit, area, areaunit, quadratshape } = rowData; try { - await connection.beginTransaction(); const quadratsData: Partial = { QuadratName: quadrat, PlotID: plotID, @@ -24,7 +23,7 @@ export async function processQuadrats(props: Readonly) { QuadratShape: quadratshape }; - const quadratID = await handleUpsert(connection, schema, 'quadrats', quadratsData, 'QuadratID'); + const { id: quadratID } = await handleUpsert(connectionManager, schema, 'quadrats', quadratsData, 'QuadratID'); if (!quadratID) throw createError('upsert failure for row: ', { quadratsData }); // need to update censusquadrat @@ -33,13 +32,11 @@ export async function processQuadrats(props: Readonly) { CensusID: censusID, QuadratID: quadratID }; - const cqID = await handleUpsert(connection, schema, 'censusquadrat', cqData, 'CQID'); + const { id: cqID } = await handleUpsert(connectionManager, schema, 'censusquadrat', cqData, 'CQID'); if (!cqID) throw createError('upsert failure on censusquadrat for row: ', { cqData }); - await connection.commit(); return quadratID; } catch (error: any) { - await connection.rollback(); console.error('Upsert failed:', error.message); throw createError('Upsert failed', { error }); } diff --git a/frontend/components/processors/processspecies.tsx b/frontend/components/processors/processspecies.tsx index a83824a3..ba1177b7 100644 --- a/frontend/components/processors/processspecies.tsx +++ b/frontend/components/processors/processspecies.tsx @@ -1,6 +1,6 @@ -import { SpecialProcessingProps } from '@/components/processors/processormacros'; import { FamilyResult, GenusResult, SpeciesResult } from '@/config/sqlrdsdefinitions/taxonomies'; import { createError, handleUpsert } from '@/config/utils'; +import { SpecialProcessingProps } from '@/config/macros'; function cleanInputData(data: any) { const cleanedData: any = {}; @@ -13,35 +13,20 @@ function cleanInputData(data: any) { } export async function processSpecies(props: Readonly): Promise { - const { connection, rowData, schema } = props; + const { connectionManager, rowData, schema } = props; console.log('rowData: ', rowData); try { - await connection.beginTransaction(); - - // Handle Family insertion/updation let familyID: number | undefined; if (rowData.family) { - try { - familyID = await handleUpsert(connection, schema, 'family', { Family: rowData.family }, 'FamilyID'); - } catch (error: any) { - console.error('Family upsert failed:', error.message); - throw createError('Family upsert failed', { error }); - } + familyID = (await handleUpsert(connectionManager, schema, 'family', { Family: rowData.family }, 'FamilyID')).id; } - // Handle Genus insertion/updation let genusID: number | undefined; if (rowData.genus) { - try { - genusID = await handleUpsert(connection, schema, 'genus', { Genus: rowData.genus, FamilyID: familyID }, 'GenusID'); - } catch (error: any) { - console.error('Genus upsert failed:', error.message); - throw createError('Genus upsert failed', { error }); - } + genusID = (await handleUpsert(connectionManager, schema, 'genus', { Genus: rowData.genus, FamilyID: familyID }, 'GenusID')).id; } - // Handle Species insertion/updation let speciesID: number | undefined; if (rowData.spcode) { const speciesData = { @@ -57,19 +42,12 @@ export async function processSpecies(props: Readonly): P const cleanedSpeciesData = cleanInputData(speciesData); console.log('Cleaned species data: ', cleanedSpeciesData); - try { - speciesID = await handleUpsert(connection, schema, 'species', cleanedSpeciesData, 'SpeciesID'); - } catch (error: any) { - console.error('Species upsert failed:', error.message); - throw createError('Species upsert failed', { error }); - } + speciesID = (await handleUpsert(connectionManager, schema, 'species', cleanedSpeciesData, 'SpeciesID')).id; } - await connection.commit(); console.log('Upsert successful'); return speciesID; } catch (error: any) { - await connection.rollback(); console.error('Upsert failed:', error.message); throw createError('Upsert failed', { error }); } diff --git a/frontend/components/sidebar.tsx b/frontend/components/sidebar.tsx index 99413faa..ce186e7f 100644 --- a/frontend/components/sidebar.tsx +++ b/frontend/components/sidebar.tsx @@ -508,12 +508,12 @@ export default function Sidebar(props: SidebarProps) { size={'md'} data-testid={'plot-select-component'} renderValue={renderPlotValue} - onChange={async (_event: React.SyntheticEvent | null, newValue: string | null) => { + onChange={async (event: React.SyntheticEvent | null, newValue: string | null) => { + event?.preventDefault(); const selectedPlot = plotListContext?.find(plot => plot?.plotName === newValue) || undefined; await handlePlotSelection(selectedPlot); }} > - {plotListContext?.map(item => (