diff --git a/frontend/.eslintrc.cjs b/frontend/.eslintrc.cjs deleted file mode 100644 index 60a5cd40..00000000 --- a/frontend/.eslintrc.cjs +++ /dev/null @@ -1,22 +0,0 @@ -module.exports = { - parser: '@typescript-eslint/parser', - extends: ['next', 'plugin:@typescript-eslint/recommended', 'plugin:prettier/recommended'], - settings: { - next: { - rootDir: '.' - } - }, - plugins: ['@typescript-eslint', 'unused-imports', 'prettier', 'import'], - rules: { - 'react-hooks/exhaustive-deps': 'off', - semi: ['error', 'always'], - 'unused-imports/no-unused-imports': 'error', - '@typescript-eslint/no-explicit-any': 'off', - '@typescript-eslint/no-unused-vars': 'off', - '@typescript-eslint/no-var-requires': 'off', - 'unused-imports/no-unused-vars': 'off', - 'react-hooks/rules-of-hooks': 'off', - 'no-case-declarations': 'off', - 'prettier/prettier': 'error' - } -}; diff --git a/frontend/.prettierrc.json b/frontend/.prettierrc similarity index 51% rename from frontend/.prettierrc.json rename to frontend/.prettierrc index af6398a9..2793d634 100644 --- a/frontend/.prettierrc.json +++ b/frontend/.prettierrc @@ -9,5 +9,19 @@ "endOfLine": "lf", "printWidth": 160, "tabWidth": 2, - "useTabs": false + "useTabs": false, + "overrides": [ + { + "files": "*.scss", + "options": { + "singleQuote": false + } + }, + { + "files": "*.html", + "options": { + "printWidth": 120 + } + } + ] } diff --git a/frontend/app/(hub)/dashboard/page.tsx b/frontend/app/(hub)/dashboard/page.tsx index 0cca9301..d8a32927 100644 --- a/frontend/app/(hub)/dashboard/page.tsx +++ b/frontend/app/(hub)/dashboard/page.tsx @@ -229,9 +229,19 @@ export default function DashboardPage() { } > - - - + + + {changelog.operation} ON {changelog.tableName} at {moment(changelog?.changeTimestamp).format('YYYY-MM-DD HH:mm:ss')} @@ -240,7 +250,13 @@ export default function DashboardPage() { - + Updating: diff --git a/frontend/app/(hub)/layout.tsx b/frontend/app/(hub)/layout.tsx index 73580131..9062409e 100644 --- a/frontend/app/(hub)/layout.tsx +++ b/frontend/app/(hub)/layout.tsx @@ -22,6 +22,7 @@ import HelpOutlineOutlinedIcon from '@mui/icons-material/HelpOutlineOutlined'; import { useLockAnimation } from '../contexts/lockanimationcontext'; import { createAndUpdateCensusList } from '@/config/sqlrdsdefinitions/timekeeping'; import { AcaciaVersionTypography } from '@/styles/versions/acaciaversion'; +import ReactDOM from 'react-dom'; const Sidebar = dynamic(() => import('@/components/sidebar'), { ssr: false }); const Header = dynamic(() => import('@/components/header'), { ssr: false }); @@ -320,6 +321,14 @@ export default function HubLayout({ children }: { children: React.ReactNode }) { const theme = useTheme(); + useEffect(() => { + if (process.env.NODE_ENV !== 'production') { + import('@axe-core/react').then(axe => { + axe.default(React, ReactDOM, 1000); + }); + } + }, []); + return ( <> setIsFeedbackModalOpen(true)} className={isPulsing ? 'animate-pulse-no-opacity' : ''} sx={{ diff --git a/frontend/app/(login)/login/page.tsx b/frontend/app/(login)/login/page.tsx index 4c5acaf2..fcd8d7e6 100644 --- a/frontend/app/(login)/login/page.tsx +++ b/frontend/app/(login)/login/page.tsx @@ -1,7 +1,7 @@ 'use client'; import React, { useEffect, useState } from 'react'; import { useSession } from 'next-auth/react'; -import { animated, useTransition } from '@react-spring/web'; +import { motion, AnimatePresence } from 'framer-motion'; import styles from '@/styles/styles.module.css'; import Box from '@mui/joy/Box'; import UnauthenticatedSidebar from '@/components/unauthenticatedsidebar'; @@ -12,38 +12,30 @@ const slides = ['background-1.jpg', 'background-2.jpg', 'background-3.jpg', 'bac export default function LoginPage() { const { data: _session, status } = useSession(); const [index, setIndex] = useState(0); - const transitions = useTransition(index, { - key: index, - from: { opacity: 0 }, - enter: { opacity: 0.5 }, - leave: { opacity: 0 }, - config: { duration: 5000 }, - onRest: (_a, _b, item) => { - if (index === item) { - setIndex(state => (state + 1) % slides.length); - } - }, - exitBeforeEnter: true - }); - // feedback received -- endless loop will consume too many resources and needs to be removed. Single loop through all slides should suffice. useEffect(() => { - setInterval(() => setIndex(state => (state + 1) % slides.length), 5000); - }, []); + const timer = setTimeout(() => { + setIndex(prevIndex => (prevIndex + 1) % slides.length); + }, 5000); + + return () => clearTimeout(timer); // Cleanup on unmount + }, [index]); if (status === 'unauthenticated') { return ( - - {transitions((style, i) => ( - + + - ))} + ); diff --git a/frontend/app/api/batchedupload/[schema]/[[...slugs]]/route.ts b/frontend/app/api/batchedupload/[schema]/[[...slugs]]/route.ts new file mode 100644 index 00000000..efd5c1e5 --- /dev/null +++ b/frontend/app/api/batchedupload/[schema]/[[...slugs]]/route.ts @@ -0,0 +1,40 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { HTTPResponses } from '@/config/macros'; +import { FailedMeasurementsRDS } from '@/config/sqlrdsdefinitions/core'; +import connectionmanager from '@/config/connectionmanager'; +import { format } from 'mysql2/promise'; + +export async function POST(request: NextRequest, props: { params: Promise<{ schema: string; slugs?: string[] }> }) { + const params = await props.params; + let errorRows: FailedMeasurementsRDS[] = await request.json(); + const schema = params.schema; + const slugs = params.slugs; + if (!errorRows || errorRows.length === 0 || !schema || !slugs || slugs.length !== 2) { + return new NextResponse(JSON.stringify({ message: 'Missing requirements!' }), { status: HTTPResponses.INVALID_REQUEST }); + } + const [plotID, censusID] = slugs.map(Number); + if (isNaN(plotID) || isNaN(censusID)) { + return new NextResponse(JSON.stringify({ message: 'Invalid plotID or censusID!' }), { status: HTTPResponses.INVALID_REQUEST }); + } + + // Add plotID and censusID to each row + errorRows = errorRows.map(row => ({ + ...row, + plotID, + censusID + })); + + const connectionManager = connectionmanager.getInstance(); + const columns = Object.keys(errorRows[0]).filter(col => col !== 'id' && col !== 'failedMeasurementID'); + const values = errorRows.map(row => columns.map(col => row[col as keyof FailedMeasurementsRDS])); + + const insertQuery = format(`INSERT INTO ?? (${columns.map(() => '??').join(', ')}) VALUES ?`, [`${schema}.failedmeasurements`, ...columns, values]); + + try { + await connectionManager.executeQuery(insertQuery); + return new NextResponse(JSON.stringify({ message: 'Insert to SQL successful' }), { status: HTTPResponses.OK }); + } catch (error: any) { + console.error('Database Error:', error); + return new NextResponse(JSON.stringify({ message: 'Database error', error: error.message }), { status: HTTPResponses.INTERNAL_SERVER_ERROR }); + } +} diff --git a/frontend/app/api/bulkcrud/route.ts b/frontend/app/api/bulkcrud/route.ts index b455d31c..63d6280c 100644 --- a/frontend/app/api/bulkcrud/route.ts +++ b/frontend/app/api/bulkcrud/route.ts @@ -7,7 +7,7 @@ import ConnectionManager from '@/config/connectionmanager'; import { Plot } from '@/config/sqlrdsdefinitions/zones'; import { OrgCensus } from '@/config/sqlrdsdefinitions/timekeeping'; -export async function POST(request: NextRequest, { params }: { params: { dataType: string; slugs?: string[] } }) { +export async function POST(request: NextRequest) { const body = await request.json(); const dataType: string = body.gridType; const schema: string = body.schema; @@ -23,8 +23,8 @@ export async function POST(request: NextRequest, { params }: { params: { dataTyp const connectionManager = ConnectionManager.getInstance(); let transactionID: string | undefined = undefined; try { + transactionID = await connectionManager.beginTransaction(); for (const rowID in rows) { - transactionID = await connectionManager.beginTransaction(); const rowData = rows[rowID]; const props: InsertUpdateProcessingProps = { schema, @@ -37,8 +37,8 @@ export async function POST(request: NextRequest, { params }: { params: { dataTyp fullName: undefined }; await insertOrUpdate(props); - await connectionManager.commitTransaction(transactionID ?? ''); } + await connectionManager.commitTransaction(transactionID ?? ''); return new NextResponse(JSON.stringify({ message: 'Insert to SQL successful' }), { status: HTTPResponses.OK }); } catch (e: any) { await connectionManager.rollbackTransaction(transactionID ?? ''); diff --git a/frontend/app/api/catalog/[firstName]/[lastName]/route.ts b/frontend/app/api/catalog/[firstName]/[lastName]/route.ts index 9cb916df..c1c362af 100644 --- a/frontend/app/api/catalog/[firstName]/[lastName]/route.ts +++ b/frontend/app/api/catalog/[firstName]/[lastName]/route.ts @@ -2,7 +2,8 @@ import { NextRequest, NextResponse } from 'next/server'; import { HTTPResponses } from '@/config/macros'; import ConnectionManager from '@/config/connectionmanager'; -export async function GET(_request: NextRequest, { params }: { params: { firstName: string; lastName: string } }) { +export async function GET(_request: NextRequest, props: { params: Promise<{ firstName: string; lastName: string }> }) { + const params = await props.params; const { firstName, lastName } = params; if (!firstName || !lastName) throw new Error('no first or last name provided!'); diff --git a/frontend/app/api/changelog/overview/[changelogType]/[[...options]]/route.ts b/frontend/app/api/changelog/overview/[changelogType]/[[...options]]/route.ts index 35be739b..a327d990 100644 --- a/frontend/app/api/changelog/overview/[changelogType]/[[...options]]/route.ts +++ b/frontend/app/api/changelog/overview/[changelogType]/[[...options]]/route.ts @@ -3,7 +3,8 @@ 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[] } }) { +export async function GET(request: NextRequest, props: { params: Promise<{ changelogType: string; options?: string[] }> }) { + const params = await props.params; const schema = request.nextUrl.searchParams.get('schema'); if (!schema) throw new Error('schema not found'); if (!params.changelogType) throw new Error('changelogType not provided'); diff --git a/frontend/app/api/clearcensus/route.ts b/frontend/app/api/clearcensus/route.ts new file mode 100644 index 00000000..4ea701f1 --- /dev/null +++ b/frontend/app/api/clearcensus/route.ts @@ -0,0 +1,57 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { HTTPResponses } from '@/config/macros'; +import ConnectionManager from '@/config/connectionmanager'; + +export async function GET(request: NextRequest) { + const schema = request.nextUrl.searchParams.get('schema'); + const censusIDParam = request.nextUrl.searchParams.get('censusID'); + if (!schema || !censusIDParam) { + return new NextResponse('Missing required parameters', { status: HTTPResponses.SERVICE_UNAVAILABLE }); + } + const censusID = parseInt(censusIDParam); + const connectionManager = ConnectionManager.getInstance(); + const transactionID = await connectionManager.beginTransaction(); + try { + let query = `DELETE FROM ${schema}.cmverrors WHERE CoreMeasurementID IN (SELECT CoreMeasurementID FROM ${schema}.coremeasurements WHERE CensusID = ${censusID});`; + await connectionManager.executeQuery(query); + query = `DELETE FROM ${schema}.cmattributes WHERE CoreMeasurementID IN (SELECT CoreMeasurementID FROM ${schema}.coremeasurements WHERE CensusID = ${censusID});`; + await connectionManager.executeQuery(query); + query = `DELETE FROM ${schema}.coremeasurements WHERE CensusID = ${censusID};`; + await connectionManager.executeQuery(query); + query = `DELETE FROM ${schema}.quadratpersonnel WHERE PersonnelID IN (SELECT PersonnelID FROM ${schema}.personnel WHERE CensusID = ${censusID});`; + await connectionManager.executeQuery(query); + query = `DELETE FROM ${schema}.personnel WHERE CensusID = ${censusID};`; + await connectionManager.executeQuery(query); + query = `DELETE FROM ${schema}.specieslimits WHERE CensusID = ${censusID};`; + await connectionManager.executeQuery(query); + query = `DELETE FROM ${schema}.censusquadrat WHERE CensusID = ${censusID};`; + await connectionManager.executeQuery(query); + query = `DELETE FROM ${schema}.quadrats WHERE QuadratID IN (SELECT QuadratID FROM ${schema}.censusquadrat WHERE CensusID = ${censusID});`; + await connectionManager.executeQuery(query); + query = `DELETE FROM ${schema}.census WHERE CensusID = ${censusID};`; + await connectionManager.executeQuery(query); + query = `ALTER TABLE ${schema}.cmverrors AUTO_INCREMENT = 1;`; + await connectionManager.executeQuery(query); + query = `ALTER TABLE ${schema}.cmattributes AUTO_INCREMENT = 1;`; + await connectionManager.executeQuery(query); + query = `ALTER TABLE ${schema}.coremeasurements AUTO_INCREMENT = 1;`; + await connectionManager.executeQuery(query); + query = `ALTER TABLE ${schema}.quadratpersonnel AUTO_INCREMENT = 1;`; + await connectionManager.executeQuery(query); + query = `ALTER TABLE ${schema}.personnel AUTO_INCREMENT = 1;`; + await connectionManager.executeQuery(query); + query = `ALTER TABLE ${schema}.specieslimits AUTO_INCREMENT = 1;`; + await connectionManager.executeQuery(query); + query = `ALTER TABLE ${schema}.censusquadrat AUTO_INCREMENT = 1;`; + await connectionManager.executeQuery(query); + query = `ALTER TABLE ${schema}.quadrats AUTO_INCREMENT = 1;`; + await connectionManager.executeQuery(query); + query = `ALTER TABLE ${schema}.census AUTO_INCREMENT = 1;`; + await connectionManager.executeQuery(query); + await connectionManager.commitTransaction(transactionID); + return NextResponse.json({ message: 'Census cleared successfully' }, { status: HTTPResponses.OK }); + } catch (e: any) { + await connectionManager.rollbackTransaction(transactionID); + return new NextResponse(e.message, { status: HTTPResponses.SERVICE_UNAVAILABLE }); + } +} diff --git a/frontend/app/api/cmprevalidation/[dataType]/[[...slugs]]/route.ts b/frontend/app/api/cmprevalidation/[dataType]/[[...slugs]]/route.ts index c4f8c058..641dd8d4 100644 --- a/frontend/app/api/cmprevalidation/[dataType]/[[...slugs]]/route.ts +++ b/frontend/app/api/cmprevalidation/[dataType]/[[...slugs]]/route.ts @@ -4,7 +4,8 @@ import ConnectionManager from '@/config/connectionmanager'; // datatype: table name // expecting 1) schema 2) plotID 3) plotCensusNumber -export async function GET(_request: NextRequest, { params }: { params: { dataType: string; slugs?: string[] } }) { +export async function GET(_request: NextRequest, props: { params: Promise<{ dataType: string; slugs?: string[] }> }) { + const params = await props.params; if (!params.slugs || !params.dataType) throw new Error('missing slugs'); const [schema, plotID, plotCensusNumber] = params.slugs; if ( diff --git a/frontend/app/api/fetchall/[[...slugs]]/route.ts b/frontend/app/api/fetchall/[[...slugs]]/route.ts index 38a2eb74..31dbe30f 100644 --- a/frontend/app/api/fetchall/[[...slugs]]/route.ts +++ b/frontend/app/api/fetchall/[[...slugs]]/route.ts @@ -45,7 +45,8 @@ const buildQuery = (schema: string, fetchType: string, plotID?: string, plotCens }; // ordering: PCQ -export async function GET(request: NextRequest, { params }: { params: { slugs?: string[] } }) { +export async function GET(request: NextRequest, props: { params: Promise<{ slugs?: string[] }> }) { + const params = await props.params; const schema = request.nextUrl.searchParams.get('schema'); if (!schema || schema === 'undefined') { throw new Error('Schema selection was not provided to API endpoint'); diff --git a/frontend/app/api/filehandlers/downloadallfiles/route.ts b/frontend/app/api/filehandlers/downloadallfiles/route.ts index 323c6f93..80f74ed4 100644 --- a/frontend/app/api/filehandlers/downloadallfiles/route.ts +++ b/frontend/app/api/filehandlers/downloadallfiles/route.ts @@ -33,7 +33,7 @@ export async function GET(request: NextRequest) { name: blob.name, user: blob.metadata?.user, formType: blob.metadata?.FormType, - fileErrors: blob.metadata?.FileErrorState ? JSON.parse(blob.metadata?.FileErrorState) : '', + fileErrors: blob.metadata?.FileErrorState ? JSON.parse(blob.metadata?.FileErrorState as string) : '', date: blob.properties.lastModified }); } diff --git a/frontend/app/api/filehandlers/storageload/route.ts b/frontend/app/api/filehandlers/storageload/route.ts index 20b91d41..4230f314 100644 --- a/frontend/app/api/filehandlers/storageload/route.ts +++ b/frontend/app/api/filehandlers/storageload/route.ts @@ -16,7 +16,7 @@ export async function POST(request: NextRequest) { const user = request.nextUrl.searchParams.get('user'); const formType = request.nextUrl.searchParams.get('formType'); const file = formData.get(fileName ?? 'file') as File | null; - const fileRowErrors = formData.get('fileRowErrors') ? JSON.parse(formData.get('fileRowErrors')) : []; + const fileRowErrors = formData.get('fileRowErrors') ? JSON.parse(formData.get('fileRowErrors') as string) : []; if ( file === null || diff --git a/frontend/app/api/fixeddata/[dataType]/[[...slugs]]/route.ts b/frontend/app/api/fixeddata/[dataType]/[[...slugs]]/route.ts index 9d0ddeb3..490ba39b 100644 --- a/frontend/app/api/fixeddata/[dataType]/[[...slugs]]/route.ts +++ b/frontend/app/api/fixeddata/[dataType]/[[...slugs]]/route.ts @@ -4,17 +4,17 @@ import { format } from 'mysql2/promise'; import { NextRequest, NextResponse } from 'next/server'; 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 +import ConnectionManager from '@/config/connectionmanager'; +import { getUpdatedValues } from '@/config/utils'; // 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( request: NextRequest, - { - params - }: { - params: { dataType: string; slugs?: string[] }; + props: { + params: Promise<{ dataType: string; slugs?: string[] }>; } ): Promise> { + const params = await props.params; if (!params.slugs || params.slugs.length < 5) throw new Error('slugs not received.'); const [schema, pageParam, pageSizeParam, plotIDParam, plotCensusNumberParam, speciesIDParam] = params.slugs; if (!schema || schema === 'undefined' || !pageParam || pageParam === 'undefined' || !pageSizeParam || pageSizeParam === 'undefined') @@ -136,7 +136,8 @@ export async function GET( } // required dynamic parameters: dataType (fixed),[ schema, gridID value] -> slugs -export async function POST(request: NextRequest, { params }: { params: { dataType: string; slugs?: string[] } }) { +export async function POST(request: NextRequest, props: { params: Promise<{ dataType: string; slugs?: string[] }> }) { + const params = await props.params; 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'); @@ -145,7 +146,7 @@ export async function POST(request: NextRequest, { params }: { params: { dataTyp const connectionManager = ConnectionManager.getInstance(); const { newRow } = await request.json(); - let insertIDs: { [key: string]: number } = {}; + let insertIDs: Record = {}; let transactionID: string | undefined = undefined; try { transactionID = await connectionManager.beginTransaction(); @@ -200,7 +201,8 @@ export async function POST(request: NextRequest, { params }: { params: { dataTyp } // slugs: schema, gridID -export async function PATCH(request: NextRequest, { params }: { params: { dataType: string; slugs?: string[] } }) { +export async function PATCH(request: NextRequest, props: { params: Promise<{ dataType: string; slugs?: string[] }> }) { + const params = await props.params; 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'); @@ -208,7 +210,7 @@ export async function PATCH(request: NextRequest, { params }: { params: { dataTy const connectionManager = ConnectionManager.getInstance(); const demappedGridID = gridID.charAt(0).toUpperCase() + gridID.substring(1); const { newRow, oldRow } = await request.json(); - let updateIDs: { [key: string]: number } = {}; + let updateIDs: Record = {}; let transactionID: string | undefined = undefined; try { @@ -231,22 +233,100 @@ export async function PATCH(request: NextRequest, { params }: { params: { dataTy // Handle non-view table updates else { - const newRowData = MapperFactory.getMapper(params.dataType).demapData([newRow])[0]; - const { [demappedGridID]: gridIDKey, ...remainingProperties } = newRowData; + if (params.dataType === 'measurementssummary') { + console.log('params datatype is ', params.dataType); + const updatedFields = getUpdatedValues(oldRow, newRow); + console.log('updated fields: ', updatedFields); + const { coreMeasurementID, quadratID, treeID, stemID, speciesID } = newRow; - // Construct the UPDATE query - const updateQuery = format( - `UPDATE ?? + const fieldGroups = { + coremeasurements: ['measuredDBH', 'measuredHOM', 'measurementDate'], + quadrats: ['quadratName'], + trees: ['treeTag'], + stems: ['stemTag', 'stemLocalX', 'stemLocalY'], + species: ['speciesName', 'subspeciesName', 'speciesCode'] + }; + + // Initialize a flag for changes + let changesFound = false; + + // Helper function to handle updates + const handleUpdate = async (groupName: keyof typeof fieldGroups, tableName: string, idColumn: string, idValue: any) => { + console.log('updating: ', groupName); + const matchingFields = Object.keys(updatedFields).reduce( + (acc, key) => { + if (fieldGroups[groupName].includes(key)) { + acc[key] = updatedFields[key]; + } + return acc; + }, + {} as Partial + ); + console.log(`matching fields for group name ${groupName}: `, matchingFields); + + if (Object.keys(matchingFields).length > 0) { + changesFound = true; + if (groupName === 'stems') { + // need to correct for key matching + if (matchingFields.stemLocalX) { + matchingFields.localX = matchingFields.stemLocalX; + delete matchingFields.stemLocalX; + } + if (matchingFields.stemLocalY) { + matchingFields.localY = matchingFields.stemLocalY; + delete matchingFields.stemLocalY; + } + } + const demappedData = MapperFactory.getMapper(groupName).demapData([matchingFields])[0]; + console.log('demapped data: ', JSON.stringify(demappedData)); + const query = format('UPDATE ?? SET ? WHERE ?? = ?', [`${schema}.${tableName}`, demappedData, idColumn, idValue]); + console.log('update query: ', query); + await connectionManager.executeQuery(query); + } + }; + + // Process each group + await handleUpdate('coremeasurements', 'coremeasurements', 'CoreMeasurementID', coreMeasurementID); + await handleUpdate('quadrats', 'quadrats', 'QuadratID', quadratID); + await handleUpdate('trees', 'trees', 'TreeID', treeID); + await handleUpdate('stems', 'stems', 'StemID', stemID); + await handleUpdate('species', 'species', 'SpeciesID', speciesID); + + // Reset validation status and clear errors if changes were made + if (changesFound) { + console.log('changes were found. resetting validation/clearing cmverrors'); + const resetValidationQuery = format('UPDATE ?? SET ?? = ? WHERE ?? = ?', [ + `${schema}.coremeasurements`, + 'IsValidated', + null, + 'CoreMeasurementID', + coreMeasurementID + ]); + console.log('reset validation query: ', resetValidationQuery); + const deleteErrorsQuery = `DELETE FROM ${schema}.cmverrors WHERE CoreMeasurementID = ${coreMeasurementID}`; + console.log('delete cmverrors query: ', deleteErrorsQuery); + await connectionManager.executeQuery(resetValidationQuery); + await connectionManager.executeQuery(deleteErrorsQuery); + } + } else { + // special handling need not apply to non-measurements tables + 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] - ); + [`${schema}.${params.dataType}`, remainingProperties, demappedGridID, gridIDKey] + ); - // Execute the UPDATE query - await connectionManager.executeQuery(updateQuery); + // Execute the UPDATE query + await connectionManager.executeQuery(updateQuery); - // For non-view tables, standardize the response format - updateIDs = { [params.dataType]: gridIDKey }; + // For non-view tables, standardize the response format + updateIDs = { [params.dataType]: gridIDKey }; + } } await connectionManager.commitTransaction(transactionID ?? ''); return NextResponse.json({ message: 'Update successful', updatedIDs: updateIDs }, { status: HTTPResponses.OK }); @@ -259,7 +339,8 @@ export async function PATCH(request: NextRequest, { params }: { params: { dataTy // 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[] } }) { +export async function DELETE(request: NextRequest, props: { params: Promise<{ dataType: string; slugs?: string[] }> }) { + const params = await props.params; 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'); diff --git a/frontend/app/api/fixeddatafilter/[dataType]/[[...slugs]]/route.ts b/frontend/app/api/fixeddatafilter/[dataType]/[[...slugs]]/route.ts index e54e44c3..a5a94c71 100644 --- a/frontend/app/api/fixeddatafilter/[dataType]/[[...slugs]]/route.ts +++ b/frontend/app/api/fixeddatafilter/[dataType]/[[...slugs]]/route.ts @@ -16,12 +16,11 @@ interface ExtendedGridFilterModel extends GridFilterModel { export async function POST( request: NextRequest, - { - params - }: { - params: { dataType: string; slugs?: string[] }; + props: { + params: Promise<{ dataType: string; slugs?: string[] }>; } ) { + const params = await props.params; // 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) { @@ -35,7 +34,7 @@ export async function POST( const connectionManager = ConnectionManager.getInstance(); const { newRow } = await request.json(); - let insertIDs: { [key: string]: number } = {}; + let insertIDs: Record = {}; let transactionID: string | undefined = undefined; try { @@ -374,15 +373,16 @@ export async function POST( } // slugs: schema, gridID -export async function PATCH(request: NextRequest, { params }: { params: { dataType: string; slugs?: string[] } }) { +export async function PATCH(request: NextRequest, props: { params: Promise<{ dataType: string; slugs?: string[] }> }) { + const params = await props.params; 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 = ConnectionManager.getInstance(); const demappedGridID = gridID.charAt(0).toUpperCase() + gridID.substring(1); - const { newRow, oldRow } = await request.json(); - let updateIDs: { [key: string]: number } = {}; + const { newRow } = await request.json(); + let updateIDs: Record = {}; let transactionID: string | undefined = undefined; try { @@ -434,7 +434,8 @@ export async function PATCH(request: NextRequest, { params }: { params: { dataTy // 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[] } }) { +export async function DELETE(request: NextRequest, props: { params: Promise<{ dataType: string; slugs?: string[] }> }) { + const params = await props.params; 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'); diff --git a/frontend/app/api/formatrunquery/route.ts b/frontend/app/api/formatrunquery/route.ts new file mode 100644 index 00000000..e721a90d --- /dev/null +++ b/frontend/app/api/formatrunquery/route.ts @@ -0,0 +1,16 @@ +import { NextRequest, NextResponse } from 'next/server'; +import ConnectionManager from '@/config/connectionmanager'; +import { format } from 'mysql2/promise'; + +// 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 body = await request.json(); // receiving query already formatted and prepped for execution + const { query, params } = body; + const connectionManager = ConnectionManager.getInstance(); + const formattedQuery = format(query, params); + const results = await connectionManager.executeQuery(formattedQuery); + return new NextResponse(JSON.stringify(results), { + status: 200, + headers: { 'Content-Type': 'application/json' } + }); +} diff --git a/frontend/app/api/formdownload/[dataType]/[[...slugs]]/route.ts b/frontend/app/api/formdownload/[dataType]/[[...slugs]]/route.ts index 86bbb2d5..61552f3b 100644 --- a/frontend/app/api/formdownload/[dataType]/[[...slugs]]/route.ts +++ b/frontend/app/api/formdownload/[dataType]/[[...slugs]]/route.ts @@ -34,18 +34,17 @@ const buildSearchStub = (columns: string[], quickFilter: string[], alias?: strin .join(' OR '); }; -export async function GET(_request: NextRequest, { params }: { params: { dataType: string; slugs?: string[] } }) { +export async function GET(_request: NextRequest, props: { params: Promise<{ dataType: string; slugs?: string[] }> }) { + const params = await props.params; const { dataType, slugs } = params; if (!dataType || !slugs) throw new Error('data type or slugs not provided'); - const [schema, plotIDParam, censusIDParam, filterModelParam] = slugs; // filtration system is optional + const [schema, plotIDParam, censusIDParam, filterModelParam] = slugs; if (!schema) throw new Error('no schema provided'); - const plotID = plotIDParam ? parseInt(plotIDParam) : undefined; const censusID = censusIDParam ? parseInt(censusIDParam) : undefined; const filterModel = filterModelParam ? JSON.parse(filterModelParam) : undefined; - const connectionManager = ConnectionManager.getInstance(); - let query: string = ''; + let query = ''; let results: any[] = []; let mappedResults: any[] = []; let formMappedResults: any[] = []; @@ -67,8 +66,8 @@ export async function GET(_request: NextRequest, { params }: { params: { dataTyp console.log('error: ', e); throw new Error(e); } - if (filterModel.quickFilterValues) searchStub = buildSearchStub(columns, filterModel.quickFilterValues); - if (filterModel.items) filterStub = buildFilterModelStub(filterModel); + if (filterModel !== undefined && filterModel.quickFilterValues) searchStub = buildSearchStub(columns, filterModel.quickFilterValues); + if (filterModel !== undefined && filterModel.items) filterStub = buildFilterModelStub(filterModel); try { switch (dataType) { case 'attributes': @@ -145,46 +144,48 @@ export async function GET(_request: NextRequest, { params }: { params: { dataTyp })); return new NextResponse(JSON.stringify(formMappedResults), { status: HTTPResponses.OK }); case 'measurements': - query = `SELECT st.StemTag AS StemTag, - t.TreeTag AS TreeTag, - s.SpeciesCode AS SpeciesCode, - q.QuadratName AS QuadratName, - st.LocalX AS StartX, - st.LocalY AS StartY, - cm.MeasuredDBH AS MeasuredDBH, - cm.MeasuredHOM AS MeasuredHOM, - cm.MeasurementDate AS MeasurementDate, - (SELECT GROUP_CONCAT(ca.Code SEPARATOR '; ') - FROM ${schema}.cmattributes ca - WHERE ca.CoreMeasurementID = cm.CoreMeasurementID) AS Codes - FROM ${schema}.coremeasurements cm - JOIN ${schema}.stems st ON st.StemID = cm.StemID - JOIN ${schema}.trees t ON t.TreeID = st.TreeID - JOIN ${schema}.quadrats q ON q.QuadratID = st.QuadratID - JOIN ${schema}.plots p ON p.PlotID = q.PlotID - JOIN ${schema}.species s ON s.SpeciesID = t.SpeciesID - WHERE p.PlotID = ? - AND cm.CensusID = ? ${ - filterModel.visible.length > 0 - ? ` AND (${filterModel.visible - .map((v: string) => { - switch (v) { - case 'valid': - return `cm.IsValidated = TRUE`; - case 'errors': - return `cm.IsValidated = FALSE`; - case 'pending': - return `cm.IsValidated IS NULL`; - default: - return null; - } - }) - .filter(Boolean) - .join(' OR ')})` - : '' - } ${searchStub || filterStub ? ` AND (${[searchStub, filterStub].filter(Boolean).join(' OR ')})` : ''}`; + query = `SELECT st.StemTag AS StemTag, + t.TreeTag AS TreeTag, + s.SpeciesCode AS SpeciesCode, + q.QuadratName AS QuadratName, + st.LocalX AS StartX, + st.LocalY AS StartY, + cm.MeasuredDBH AS MeasuredDBH, + cm.MeasuredHOM AS MeasuredHOM, + cm.MeasurementDate AS MeasurementDate, + (SELECT GROUP_CONCAT(ca.Code SEPARATOR '; ') + FROM ${schema}.cmattributes ca + WHERE ca.CoreMeasurementID = cm.CoreMeasurementID) AS Codes, + (SELECT GROUP_CONCAT(CONCAT(vp.ProcedureName, ':', vp.Description) SEPARATOR ';') + FROM catalog.validationprocedures vp + JOIN ${schema}.cmverrors cmv ON cmv.ValidationErrorID = vp.ValidationID + WHERE cmv.CoreMeasurementID = cm.CoreMeasurementID) AS Errors + FROM ${schema}.coremeasurements cm + JOIN ${schema}.stems st ON st.StemID = cm.StemID + JOIN ${schema}.trees t ON t.TreeID = st.TreeID + JOIN ${schema}.quadrats q ON q.QuadratID = st.QuadratID + JOIN ${schema}.plots p ON p.PlotID = q.PlotID + JOIN ${schema}.species s ON s.SpeciesID = t.SpeciesID + WHERE p.PlotID = ? AND cm.CensusID = ? ${ + filterModel.visible.length > 0 + ? ` AND (${filterModel.visible + .map((v: string) => { + switch (v) { + case 'valid': + return `cm.IsValidated = TRUE`; + case 'errors': + return `cm.IsValidated = FALSE`; + case 'pending': + return `cm.IsValidated IS NULL`; + default: + return null; + } + }) + .filter(Boolean) + .join(' OR ')})` + : '' + } ${searchStub || filterStub ? ` AND (${[searchStub, filterStub].filter(Boolean).join(' OR ')})` : ''}`; results = await connectionManager.executeQuery(query, [plotID, censusID]); - // console.log('results: ', results); formMappedResults = results.map((row: any) => ({ tag: row.TreeTag, stemtag: row.StemTag, @@ -195,7 +196,8 @@ export async function GET(_request: NextRequest, { params }: { params: { dataTyp dbh: row.MeasuredDBH, hom: row.MeasuredHOM, date: row.MeasurementDate, - codes: row.Codes + codes: row.Codes, + errors: row.Errors })); return new NextResponse(JSON.stringify(formMappedResults), { status: HTTPResponses.OK }); default: diff --git a/frontend/app/api/formvalidation/[dataType]/[[...slugs]]/route.ts b/frontend/app/api/formvalidation/[dataType]/[[...slugs]]/route.ts index 9e143cae..c8b56399 100644 --- a/frontend/app/api/formvalidation/[dataType]/[[...slugs]]/route.ts +++ b/frontend/app/api/formvalidation/[dataType]/[[...slugs]]/route.ts @@ -7,7 +7,8 @@ import ConnectionManager from '@/config/connectionmanager'; // slugs: schema, columnName, value ONLY // needs to match dynamic format established by other slug routes! // refit to match entire rows, using dataType convention to determine what columns need testing? -export async function GET(request: NextRequest, { params }: { params: { dataType: string; slugs?: string[] } }) { +export async function GET(request: NextRequest, props: { params: Promise<{ dataType: string; slugs?: string[] }> }) { + const params = await props.params; // simple dynamic validation to confirm table input values: if (!params.slugs || params.slugs.length !== 3) throw new Error('slugs missing -- formvalidation'); if (!params.dataType || params.dataType === 'undefined') throw new Error('no schema provided'); 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 2a4b2450..cc42ee50 100644 --- a/frontend/app/api/postvalidationbyquery/[schema]/[plotID]/[censusID]/[queryID]/route.ts +++ b/frontend/app/api/postvalidationbyquery/[schema]/[plotID]/[censusID]/[queryID]/route.ts @@ -3,7 +3,8 @@ import { HTTPResponses } from '@/config/macros'; 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 } }) { +export async function GET(_request: NextRequest, props: { params: Promise<{ schema: string; plotID: string; censusID: string; queryID: string }> }) { + const params = await props.params; const { schema } = params; const plotID = parseInt(params.plotID); const censusID = parseInt(params.censusID); diff --git a/frontend/app/api/refreshviews/[view]/[schema]/route.ts b/frontend/app/api/refreshviews/[view]/[schema]/route.ts index f2888d5b..6c0ce992 100644 --- a/frontend/app/api/refreshviews/[view]/[schema]/route.ts +++ b/frontend/app/api/refreshviews/[view]/[schema]/route.ts @@ -2,7 +2,8 @@ import { HTTPResponses } from '@/config/macros'; import { NextRequest, NextResponse } from 'next/server'; import ConnectionManager from '@/config/connectionmanager'; -export async function POST(_request: NextRequest, { params }: { params: { view: string; schema: string } }) { +export async function POST(_request: NextRequest, props: { params: Promise<{ view: string; schema: string }> }) { + const params = await props.params; if (!params.schema || params.schema === 'undefined' || !params.view || params.view === 'undefined' || !params) throw new Error('schema not provided'); const { view, schema } = params; const connectionManager = ConnectionManager.getInstance(); diff --git a/frontend/app/api/rollover/[dataType]/[[...slugs]]/route.ts b/frontend/app/api/rollover/[dataType]/[[...slugs]]/route.ts index 7c2447d2..60eda8a8 100644 --- a/frontend/app/api/rollover/[dataType]/[[...slugs]]/route.ts +++ b/frontend/app/api/rollover/[dataType]/[[...slugs]]/route.ts @@ -9,7 +9,8 @@ import ConnectionManager from '@/config/connectionmanager'; * @param params - The route parameters, including the `dataType` (either 'quadrats' or 'personnel') and the `slugs` (an array containing the schema, plotID, sourceCensusID, and newCensusID). * @returns A NextResponse with a success message or an error message, along with the appropriate HTTP status code. */ -export async function POST(request: NextRequest, { params }: { params: { dataType: string; slugs?: string[] } }) { +export async function POST(request: NextRequest, props: { params: Promise<{ dataType: string; slugs?: string[] }> }) { + const params = await props.params; if (!params.slugs) throw new Error('slugs not provided'); const [schema, plotID, sourceCensusID, newCensusID] = params.slugs; if (!schema || !plotID || !sourceCensusID || !newCensusID) throw new Error('no schema or plotID or censusID provided'); diff --git a/frontend/app/api/runquery/route.ts b/frontend/app/api/runquery/route.ts index 27e5f4d3..913540ba 100644 --- a/frontend/app/api/runquery/route.ts +++ b/frontend/app/api/runquery/route.ts @@ -4,7 +4,6 @@ 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 = ConnectionManager.getInstance(); const results = await connectionManager.executeQuery(query); return new NextResponse(JSON.stringify(results), { diff --git a/frontend/app/api/specieslimits/[plotID]/[plotCensusNumber]/route.ts b/frontend/app/api/specieslimits/[plotID]/[plotCensusNumber]/route.ts index a4e18fda..da807868 100644 --- a/frontend/app/api/specieslimits/[plotID]/[plotCensusNumber]/route.ts +++ b/frontend/app/api/specieslimits/[plotID]/[plotCensusNumber]/route.ts @@ -4,7 +4,8 @@ import { HTTPResponses } from '@/config/macros'; import ConnectionManager from '@/config/connectionmanager'; // pulls everything -export async function GET(request: NextRequest, { params }: { params: { plotID: string; plotCensusNumber: string } }) { +export async function GET(request: NextRequest, props: { params: Promise<{ plotID: string; plotCensusNumber: string }> }) { + const params = await props.params; const schema = request.nextUrl.searchParams.get('schema'); if (!schema) throw new Error('Schema not provided'); if (isNaN(parseInt(params.plotID)) || isNaN(parseInt(params.plotCensusNumber))) throw new Error('required slugs were not provided'); diff --git a/frontend/app/api/sqlload/route.ts b/frontend/app/api/sqlload/route.ts deleted file mode 100644 index ac423cd5..00000000 --- a/frontend/app/api/sqlload/route.ts +++ /dev/null @@ -1,81 +0,0 @@ -import { NextRequest, NextResponse } from 'next/server'; -import { HTTPResponses, InsertUpdateProcessingProps } from '@/config/macros'; -import { FileRow, FileRowSet } from '@/config/macros/formdetails'; -import { insertOrUpdate } from '@/components/processors/processorhelperfunctions'; -import ConnectionManager from '@/config/connectionmanager'; -import { Plot } from '@/config/sqlrdsdefinitions/zones'; -import { OrgCensus } from '@/config/sqlrdsdefinitions/timekeeping'; - -export async function POST(request: NextRequest) { - const body = await request.json(); - const schema: string = body.schema; - const formType: string = body.formType; - const fileName: string = body.fileName; - const plot: Plot = body.plot; - const census: OrgCensus = body.census; - const user: string = body.user; - const fileRowSet: FileRowSet = body.fileRowSet; - - const connectionManager = ConnectionManager.getInstance(); - - const idToRows: { coreMeasurementID: number; fileRow: FileRow }[] = []; - for (const rowId in fileRowSet) { - // await connectionManager.beginTransaction(); - const row = fileRowSet[rowId]; - try { - const props: InsertUpdateProcessingProps = { - schema, - connectionManager: connectionManager, - formType, - rowData: row, - plot, - census, - fullName: user - }; - const coreMeasurementID = await insertOrUpdate(props); - if (formType === 'measurements' && coreMeasurementID) { - idToRows.push({ coreMeasurementID: coreMeasurementID, fileRow: row }); - } else if (formType === 'measurements' && coreMeasurementID === undefined) { - throw new Error('CoreMeasurement insertion failure at row: ' + row); - } - // await connectionManager.commitTransaction(); - } catch (error) { - // await connectionManager.rollbackTransaction(); - await connectionManager.closeConnection(); - if (error instanceof Error) { - console.error(`Error processing row for file ${fileName}:`, error.message); - return new NextResponse( - JSON.stringify({ - responseMessage: `Error processing row in file ${fileName}`, - error: error.message - }), - { status: HTTPResponses.SERVICE_UNAVAILABLE } - ); - } else { - console.error('Unknown error processing row:', error); - return new NextResponse( - JSON.stringify({ - responseMessage: `Unknown processing error at row, in file ${fileName}` - }), - { status: HTTPResponses.SERVICE_UNAVAILABLE } - ); - } - } - } - - // Update Census Start/End Dates - // const combinedQuery = ` - // UPDATE ${schema}.census c - // JOIN ( - // SELECT CensusID, MIN(MeasurementDate) AS FirstMeasurementDate, MAX(MeasurementDate) AS LastMeasurementDate - // FROM ${schema}.coremeasurements - // WHERE CensusID = ${censusID} - // GROUP BY CensusID - // ) m ON c.CensusID = m.CensusID - // SET c.StartDate = m.FirstMeasurementDate, c.EndDate = m.LastMeasurementDate - // WHERE c.CensusID = ${censusID};`; - // - // await connectionManager.executeQuery(combinedQuery); - // await connectionManager.closeConnection(); - return new NextResponse(JSON.stringify({ message: 'Insert to SQL successful', idToRows: idToRows }), { status: HTTPResponses.OK }); -} diff --git a/frontend/app/api/structure/[schema]/route.ts b/frontend/app/api/structure/[schema]/route.ts index 47df9f4a..bcec0c2e 100644 --- a/frontend/app/api/structure/[schema]/route.ts +++ b/frontend/app/api/structure/[schema]/route.ts @@ -1,7 +1,8 @@ import { NextRequest } from 'next/server'; import ConnectionManager from '@/config/connectionmanager'; -export async function GET(_request: NextRequest, { params }: { params: { schema: string } }) { +export async function GET(_request: NextRequest, props: { params: Promise<{ schema: string }> }) { + const params = await props.params; const schema = params.schema; if (!schema) throw new Error('no schema variable provided!'); const query = `SELECT table_name, column_name diff --git a/frontend/app/api/validations/procedures/[validationType]/route.ts b/frontend/app/api/validations/procedures/[validationType]/route.ts index 3760bb03..a253e1aa 100644 --- a/frontend/app/api/validations/procedures/[validationType]/route.ts +++ b/frontend/app/api/validations/procedures/[validationType]/route.ts @@ -2,7 +2,8 @@ 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: { validationType: string } }) { +export async function POST(request: NextRequest, props: { params: Promise<{ validationType: string }> }) { + const params = await props.params; try { if (!params.validationType) throw new Error('validationProcedureName not provided'); const body = await request.json(); diff --git a/frontend/app/api/validations/validationlist/route.ts b/frontend/app/api/validations/validationlist/route.ts index 4a52b108..fdc4ada2 100644 --- a/frontend/app/api/validations/validationlist/route.ts +++ b/frontend/app/api/validations/validationlist/route.ts @@ -2,23 +2,21 @@ import { HTTPResponses } from '@/config/macros'; import { NextRequest, NextResponse } from 'next/server'; import ConnectionManager from '@/config/connectionmanager'; -type ValidationProcedure = { +interface ValidationProcedure { ValidationID: number; ProcedureName: string; Description: string; Definition: string; -}; +} -type SiteSpecificValidations = { +interface SiteSpecificValidations { ValidationProcedureID: number; Name: string; Description: string; Definition: string; -}; +} -type ValidationMessages = { - [key: string]: { id: number; description: string; definition: string }; -}; +type ValidationMessages = Record; export async function GET(request: NextRequest): Promise> { const conn = ConnectionManager.getInstance(); diff --git a/frontend/app/contexts/listselectionprovider.tsx b/frontend/app/contexts/listselectionprovider.tsx index ac7870a2..753e20fc 100644 --- a/frontend/app/contexts/listselectionprovider.tsx +++ b/frontend/app/contexts/listselectionprovider.tsx @@ -1,6 +1,6 @@ // ListSelectionProvider.tsx 'use client'; -import React, { createContext, Dispatch, useContext, useReducer } from 'react'; +import React, { createContext, Dispatch, Reducer, useContext, useReducer } from 'react'; import { createEnhancedDispatch, EnhancedDispatch, genericLoadReducer, LoadAction } from '@/config/macros/contextreducers'; import { PlotRDS, QuadratRDS, SitesRDS } from '@/config/sqlrdsdefinitions/zones'; import { OrgCensus } from '@/config/sqlrdsdefinitions/timekeeping'; @@ -19,13 +19,18 @@ export const SiteListDispatchContext = createContext | undefined>(undefined); export function ListSelectionProvider({ children }: Readonly<{ children: React.ReactNode }>) { - const [plotList, plotListDispatch] = useReducer>>(genericLoadReducer, []); + const plotListReducer: Reducer> = genericLoadReducer; + const orgCensusListReducer: Reducer> = genericLoadReducer; + const quadratListReducer: Reducer> = genericLoadReducer; + const siteListReducer: Reducer> = genericLoadReducer; - const [orgCensusList, orgCensusListDispatch] = useReducer>>(genericLoadReducer, []); + const [plotList, plotListDispatch] = useReducer(plotListReducer, []); - const [quadratList, quadratListDispatch] = useReducer>>(genericLoadReducer, []); + const [orgCensusList, orgCensusListDispatch] = useReducer(orgCensusListReducer, []); - const [siteList, siteListDispatch] = useReducer>>(genericLoadReducer, []); + const [quadratList, quadratListDispatch] = useReducer(quadratListReducer, []); + + const [siteList, siteListDispatch] = useReducer(siteListReducer, []); const [firstLoad, firstLoadDispatch] = useReducer(firstLoadReducer, true); diff --git a/frontend/components/client/codeeditor.tsx b/frontend/components/client/codeeditor.tsx new file mode 100644 index 00000000..3e99801e --- /dev/null +++ b/frontend/components/client/codeeditor.tsx @@ -0,0 +1,66 @@ +'use client'; + +import React, { Dispatch, SetStateAction, useRef } from 'react'; +import { Box } from '@mui/joy'; +import { useCodeMirror } from '@uiw/react-codemirror'; +import { basicSetup } from 'codemirror'; +import { sql } from '@codemirror/lang-sql'; +import { autocompletion, CompletionContext } from '@codemirror/autocomplete'; + +type CodeEditorProps = { + value: string; + setValue: Dispatch> | undefined; + schemaDetails?: { table_name: string; column_name: string }[]; + height?: string | number; + isDarkMode?: boolean; + readOnly?: boolean; +}; + +const CodeEditor: React.FC = ({ value, setValue, schemaDetails = [], height = '60vh', isDarkMode = false, readOnly = false }) => { + const editorContainerRef = useRef(null); + + // Autocomplete Suggestions + const autocompleteExtension = autocompletion({ + override: [ + (context: CompletionContext) => { + const word = context.matchBefore(/\w*/); + if (!word || word.from === word.to) return null; + + const suggestions = [ + ...Array.from(new Set(schemaDetails.map(row => row.table_name))).map(table => ({ + label: table, + type: 'keyword', + detail: 'Table', + apply: table + })), + ...schemaDetails.map(({ table_name, column_name }) => ({ + label: `${table_name}.${column_name}`, + type: 'property', + detail: `Column from ${table_name}`, + apply: `${table_name}.${column_name}` + })) + ]; + + return { + from: word.from, + options: suggestions + }; + } + ] + }); + + // Initialize CodeMirror + useCodeMirror({ + container: editorContainerRef.current, + value, + height: height.toString(), + extensions: [basicSetup, sql(), autocompleteExtension], + theme: isDarkMode ? 'dark' : 'light', + onChange: setValue, + readOnly + }); + + return ; +}; + +export default CodeEditor; diff --git a/frontend/components/client/custommonacoeditor.tsx b/frontend/components/client/custommonacoeditor.tsx deleted file mode 100644 index ecb552fb..00000000 --- a/frontend/components/client/custommonacoeditor.tsx +++ /dev/null @@ -1,128 +0,0 @@ -'use client'; - -import { useMonaco } from '@monaco-editor/react'; -import dynamic from 'next/dynamic'; -import React, { Dispatch, memo, SetStateAction, useEffect, useState } from 'react'; -import { Box, Button, Snackbar } from '@mui/joy'; - -const Editor = dynamic(() => import('@monaco-editor/react'), { ssr: false }); - -type CustomMonacoEditorProps = { - schemaDetails: { - table_name: string; - column_name: string; - }[]; - setContent?: Dispatch>; - content?: string; - height?: any; - isDarkMode?: boolean; -} & React.ComponentPropsWithoutRef; - -function processExplainOutput(explainRows: any[]): { valid: boolean; message: string } { - for (const row of explainRows) { - if (row.Extra?.includes('Impossible WHERE')) { - return { valid: false, message: 'The WHERE clause is impossible to satisfy.' }; - } - - if (!row.table) { - return { valid: false, message: 'Invalid table reference.' }; - } - - if (!row.key && row.possible_keys) { - return { valid: false, message: `No indexes are being used for table ${row.table}. Consider adding an index.` }; - } - } - - return { valid: true, message: 'The query is valid.' }; -} - -function CustomMonacoEditor(broadProps: CustomMonacoEditorProps) { - const { schemaDetails, setContent = () => {}, content, height, options = {}, isDarkMode, ...props } = broadProps; - const monaco = useMonaco(); - const [snackbarContent, setSnackbarContent] = useState<{ valid: boolean; message: string } | undefined>(); - const [openSnackbar, setOpenSnackbar] = useState(false); - - async function validateQuery() { - const response = await fetch(`/api/runquery`, { method: 'POST', body: JSON.stringify('EXPLAIN ' + content) }); - const data = await response.json(); - const { valid, message } = processExplainOutput(data); - - setSnackbarContent({ valid, message }); - setOpenSnackbar(true); - } - - useEffect(() => { - if (monaco) { - monaco.languages.registerCompletionItemProvider('mysql', { - provideCompletionItems: (model, position) => { - const suggestions: any[] = []; - const word = model.getWordUntilPosition(position); - const range = new monaco.Range(position.lineNumber, word.startColumn, position.lineNumber, word.endColumn); - - const tables = Array.from(new Set(schemaDetails.map(row => row.table_name))); - tables.forEach(table => { - suggestions.push({ - label: table, - kind: monaco.languages.CompletionItemKind.Function, - insertText: table, - detail: 'Table', - range - }); - }); - - schemaDetails.forEach(({ table_name, column_name }) => { - suggestions.push({ - label: `${table_name}.${column_name}`, - kind: monaco.languages.CompletionItemKind.Property, - insertText: `${table_name}.${column_name}`, - detail: `Column from ${table_name}`, - range - }); - }); - - return { suggestions }; - } - }); - } - }, [monaco]); - - return ( - <> - - - setContent(value ?? '')} - theme={isDarkMode ? 'vs-dark' : 'light'} - options={{ - ...options, - readOnly: options.readOnly ?? false - }} - {...props} - /> - - setOpenSnackbar(false)} size="sm" variant="soft"> - Dismiss - - } - onClose={() => { - setSnackbarContent(undefined); - }} - > - Query validation results: {snackbarContent?.valid ? 'Valid' : 'Invalid'}
- Details: {snackbarContent?.message} -
- - ); -} - -export default memo(CustomMonacoEditor); diff --git a/frontend/components/client/datagridcolumns.tsx b/frontend/components/client/datagridcolumns.tsx index 14811d91..67551220 100644 --- a/frontend/components/client/datagridcolumns.tsx +++ b/frontend/components/client/datagridcolumns.tsx @@ -1,7 +1,7 @@ -import { HEADER_ALIGN, unitSelectionOptions } from '@/config/macros'; -import { Box, FormHelperText, Input, Option, Select, Stack, Typography } from '@mui/joy'; -import { GridColDef, useGridApiRef } from '@mui/x-data-grid'; -import React, { useEffect, useState } from 'react'; +import { HEADER_ALIGN } from '@/config/macros'; +import { Box, Stack, Typography } from '@mui/joy'; +import { GridColDef } from '@mui/x-data-grid'; +import React from 'react'; import { AttributeStatusOptions } from '@/config/sqlrdsdefinitions/core'; import { standardizeGridColumns } from '@/components/client/clientmacros'; import { customNumericOperators } from '@/components/datagrids/filtrationsystem'; @@ -224,89 +224,89 @@ export const StemTaxonomiesViewGridColumns: GridColDef[] = standardizeGridColumn // note --> originally attempted to use GridValueFormatterParams, but this isn't exported by MUI X DataGrid anymore. replaced with for now. -const renderValueCell = (params: any, valueKey: string, unitKey: string) => { - const value = params.row[valueKey] ? Number(params.row[valueKey]).toFixed(2) : 'null'; - const units = params.row[unitKey] ? (params.row[valueKey] !== null ? params.row[unitKey] : '') : ''; - - return ( - - {value && {value}} - {units && {units}} - - ); -}; - -const renderEditValueCell = (params: any, valueKey: string, unitKey: string, placeholder: string) => { - const apiRef = useGridApiRef(); - const { id, row } = params; - const [error, setError] = useState(false); - const [value, setValue] = useState(row[valueKey]); - - const handleValueChange = (event: any) => { - const inputValue = event.target.value; - const isValid = /^\d*\.?\d{0,2}$/.test(inputValue); - setError(!isValid); - if (isValid) { - setValue(inputValue); - } - }; - - const handleValueBlur = () => { - const truncatedValue = Number(value).toFixed(2); - apiRef.current.setEditCellValue({ id, field: valueKey, value: truncatedValue }); - }; - - const handleUnitsChange = (_event: any, newValue: any) => { - if (newValue !== null) { - apiRef.current.setEditCellValue({ id, field: unitKey, value: newValue }); - } - }; - - useEffect(() => { - setValue(row[valueKey]); - }, [row[valueKey]]); - - return ( - - - - {error && ( - - Only numbers with up to 2 decimal places accepted! - - )} - - - - ); -}; - -export const renderDBHCell = (params: any) => renderValueCell(params, 'measuredDBH', ''); -export const renderEditDBHCell = (params: any) => renderEditValueCell(params, 'measuredDBH', '', 'Diameter at breast height (DBH)'); -export const renderHOMCell = (params: any) => renderValueCell(params, 'measuredHOM', ''); -export const renderEditHOMCell = (params: any) => renderEditValueCell(params, 'measuredHOM', '', 'Height of Measure (HOM)'); -export const renderStemXCell = (params: any) => renderValueCell(params, 'localStemX', ''); -export const renderEditStemXCell = (params: any) => renderEditValueCell(params, 'localStemX', '', 'Stem Local X Coordinates'); -export const renderStemYCell = (params: any) => renderValueCell(params, 'localStemY', ''); -export const renderEditStemYCell = (params: any) => renderEditValueCell(params, 'localStemY', '', 'Stem Local Y Coordinates'); +// const renderValueCell = (params: any, valueKey: string, unitKey: string) => { +// const value = params.row[valueKey] ? Number(params.row[valueKey]).toFixed(2) : 'null'; +// const units = params.row[unitKey] ? (params.row[valueKey] !== null ? params.row[unitKey] : '') : ''; +// +// return ( +// +// {value && {value}} +// {units && {units}} +// +// ); +// }; +// +// const renderEditValueCell = (params: any, valueKey: string, unitKey: string, placeholder: string) => { +// const apiRef = useGridApiContext(); +// const { id, row } = params; +// const [error, setError] = useState(false); +// const [value, setValue] = useState(row[valueKey]); +// +// const handleValueChange = (event: any) => { +// const inputValue = event.target.value; +// const isValid = /^\d*\.?\d{0,2}$/.test(inputValue); +// setError(!isValid); +// if (isValid) { +// setValue(inputValue); +// } +// }; +// +// const handleValueBlur = () => { +// const truncatedValue = Number(value).toFixed(2); +// apiRef.current.setEditCellValue({ id, field: valueKey, value: truncatedValue }); +// }; +// +// const handleUnitsChange = (_event: any, newValue: any) => { +// if (newValue !== null) { +// apiRef.current.setEditCellValue({ id, field: unitKey, value: newValue }); +// } +// }; +// +// useEffect(() => { +// setValue(row[valueKey]); +// }, [row[valueKey]]); +// +// return ( +// +// +// +// {error && ( +// +// Only numbers with up to 2 decimal places accepted! +// +// )} +// +// +// +// ); +// }; +// +// export const renderDBHCell = (params: any) => renderValueCell(params, 'measuredDBH', ''); +// export const renderEditDBHCell = (params: any) => renderEditValueCell(params, 'measuredDBH', '', 'Diameter at breast height (DBH)'); +// export const renderHOMCell = (params: any) => renderValueCell(params, 'measuredHOM', ''); +// export const renderEditHOMCell = (params: any) => renderEditValueCell(params, 'measuredHOM', '', 'Height of Measure (HOM)'); +// export const renderStemXCell = (params: any) => renderValueCell(params, 'localStemX', ''); +// export const renderEditStemXCell = (params: any) => renderEditValueCell(params, 'localStemX', '', 'Stem Local X Coordinates'); +// export const renderStemYCell = (params: any) => renderValueCell(params, 'localStemY', ''); +// export const renderEditStemYCell = (params: any) => renderEditValueCell(params, 'localStemY', '', 'Stem Local Y Coordinates'); export const MeasurementsSummaryViewGridColumns: GridColDef[] = standardizeGridColumns([ { @@ -376,8 +376,6 @@ export const MeasurementsSummaryViewGridColumns: GridColDef[] = standardizeGridC return Number(value).toFixed(2); }, maxWidth: 100, - renderCell: renderStemXCell, - renderEditCell: renderEditStemXCell, editable: true, filterOperators: customNumericOperators }, @@ -387,12 +385,10 @@ export const MeasurementsSummaryViewGridColumns: GridColDef[] = standardizeGridC renderHeader: () => formatHeader('Y', 'Stem'), flex: 0.7, type: 'number', + maxWidth: 100, valueFormatter: (value: any) => { return Number(value).toFixed(2); }, - maxWidth: 100, - renderCell: renderStemYCell, - renderEditCell: renderEditStemYCell, editable: true, filterOperators: customNumericOperators }, @@ -401,8 +397,9 @@ export const MeasurementsSummaryViewGridColumns: GridColDef[] = standardizeGridC headerName: 'DBH', flex: 0.5, editable: true, - renderCell: renderDBHCell, - renderEditCell: renderEditDBHCell, + valueFormatter: (value: any) => { + return Number(value).toFixed(2); + }, filterOperators: customNumericOperators }, { @@ -410,8 +407,9 @@ export const MeasurementsSummaryViewGridColumns: GridColDef[] = standardizeGridC headerName: 'HOM', flex: 0.5, editable: true, - renderCell: renderHOMCell, - renderEditCell: renderEditHOMCell, + valueFormatter: (value: any) => { + return Number(value).toFixed(2); + }, filterOperators: customNumericOperators }, { diff --git a/frontend/components/client/githubfeedbackmodal.tsx b/frontend/components/client/githubfeedbackmodal.tsx index 57d3751f..76dd414f 100644 --- a/frontend/components/client/githubfeedbackmodal.tsx +++ b/frontend/components/client/githubfeedbackmodal.tsx @@ -69,14 +69,12 @@ const issueTypes = [ type IssueType = (typeof issueTypes)[number]['value']; -type GithubFeedbackModalProps = { +interface GithubFeedbackModalProps { open: boolean; onClose: () => void; -}; +} -type Issue = { - [key: string]: any; -}; +type Issue = Record; const formatHeaders = (headers: any) => { const importantHeaders = ['content-type', 'etag', 'x-github-request-id']; diff --git a/frontend/components/client/postvalidationrow.tsx b/frontend/components/client/postvalidationrow.tsx index 7a32c22d..003615e1 100644 --- a/frontend/components/client/postvalidationrow.tsx +++ b/frontend/components/client/postvalidationrow.tsx @@ -9,6 +9,7 @@ import { Done } from '@mui/icons-material'; import moment from 'moment/moment'; import { darken } from '@mui/system'; import dynamic from 'next/dynamic'; +import CodeEditor from '@/components/client/codeeditor'; interface PostValidationRowProps { postValidation: PostValidationQueriesRDS; @@ -36,7 +37,7 @@ const PostValidationRow: React.FC = ({ schemaDetails }) => { const formattedResults = JSON.stringify(JSON.parse(postValidation.lastRunResult ?? '{}'), null, 2); - const CustomMonacoEditor = dynamic(() => import('@/components/client/custommonacoeditor'), { ssr: false }); + const CustomMonacoEditor = dynamic(() => import('@/components/client/codeeditor'), { ssr: false }); const successColor = !isDarkMode ? 'rgba(54, 163, 46, 0.3)' : darken('rgba(54,163,46,0.6)', 0.7); const failureColor = !isDarkMode ? 'rgba(255, 0, 0, 0.3)' : darken('rgba(255,0,0,0.6)', 0.7); @@ -120,21 +121,15 @@ const PostValidationRow: React.FC = ({ }} > {expandedQuery === postValidation.queryID ? ( - + value={postValidation.queryDefinition!.replace(/\${(.*?)}/g, (_match: any, p1: string) => String(replacements[p1 as keyof typeof replacements] ?? '') )} - setContent={undefined} + setValue={undefined} height={`${Math.min(300, 20 * (postValidation?.queryDefinition ?? '').split('\n').length)}px`} isDarkMode={isDarkMode} - options={{ - readOnly: true, - minimap: { enabled: false }, - scrollBeyondLastLine: false, - wordWrap: 'off', - lineNumbers: 'off' - }} + readOnly={true} /> ) : (