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/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/components/datagrids/applications/msveditingmodal.tsx b/frontend/components/datagrids/applications/msveditingmodal.tsx new file mode 100644 index 00000000..7497b14a --- /dev/null +++ b/frontend/components/datagrids/applications/msveditingmodal.tsx @@ -0,0 +1,262 @@ +'use client'; + +import { GridRowModel } from '@mui/x-data-grid'; +import React, { useEffect, useState } from 'react'; +import { + Button, + DialogActions, + DialogContent, + DialogTitle, + Modal, + ModalDialog, + Step, + stepClasses, + StepIndicator, + stepIndicatorClasses, + Stepper, + Typography +} from '@mui/joy'; +import { getUpdatedValues } from '@/config/utils'; +import MapperFactory from '@/config/datamapper'; +import { useSiteContext } from '@/app/contexts/userselectionprovider'; +import { Diversity2, Forest, Grass, GridView, PrecisionManufacturing } from '@mui/icons-material'; +import { v4 } from 'uuid'; + +interface MSVEditingProps { + gridType: string; + oldRow: GridRowModel; + newRow: GridRowModel; + handleClose: () => void; + handleSave: (confirmedRow: GridRowModel) => void; +} + +export default function MSVEditingModal(props: MSVEditingProps) { + const currentSite = useSiteContext(); + const { gridType, handleClose, oldRow, newRow, handleSave } = props; + const updatedFields = getUpdatedValues(oldRow, newRow); + const { coreMeasurementID, quadratID, treeID, stemID, speciesID } = newRow; + const fieldGroups = { + coremeasurements: ['measuredDBH', 'measuredHOM', 'measurementDate'], + quadrats: ['quadratName'], + trees: ['treeTag'], + stems: ['stemTag', 'stemLocalX', 'stemLocalY'], + species: ['speciesName', 'subspeciesName', 'speciesCode'] + }; + type UploadStatus = 'idle' | 'in-progress' | 'completed' | 'error'; + const [beginUpload, setBeginUpload] = useState(false); + const [isConfirmStep, setIsConfirmStep] = useState(false); + const [uploadStatus, setUploadStatus] = useState<{ + [Key in keyof typeof fieldGroups]: UploadStatus; + }>({ + coremeasurements: 'idle', + quadrats: 'idle', + trees: 'idle', + stems: 'idle', + species: 'idle' + }); + const stepIcons = [, , , , ]; + + const handleUpdate = async (groupName: keyof typeof fieldGroups, tableName: string, idColumn: string, idValue: any) => { + console.log('handle update entered for group name: ', groupName); + setUploadStatus(prev => ({ + ...prev, + [groupName]: 'in-progress' + })); + 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) { + console.log('match found: '); + 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; + } + } + try { + const demappedData = MapperFactory.getMapper(groupName).demapData([matchingFields])[0]; + const query = `UPDATE ?? SET ? WHERE ?? = ?`; + const response = await fetch(`/api/formatrunquery`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ query: query, params: [`${currentSite?.schemaName}.${tableName}`, demappedData, idColumn, idValue] }) + }); + if (response.ok) + setUploadStatus(prev => ({ + ...prev, + [groupName]: 'completed' + })); + else throw new Error(`err`); + } catch (e) { + console.error(e); + setUploadStatus(prev => ({ + ...prev, + [groupName]: 'error' + })); + } + } else { + setUploadStatus(prev => ({ + ...prev, + [groupName]: 'completed' + })); + } + }; + + const handleBeginUpload = async () => { + await handleUpdate('coremeasurements', 'coremeasurements', 'CoreMeasurementID', coreMeasurementID); + await new Promise(resolve => setTimeout(resolve, 1000)); + await handleUpdate('quadrats', 'quadrats', 'QuadratID', quadratID); + await new Promise(resolve => setTimeout(resolve, 1000)); + await handleUpdate('trees', 'trees', 'TreeID', treeID); + await new Promise(resolve => setTimeout(resolve, 1000)); + await handleUpdate('stems', 'stems', 'StemID', stemID); + await new Promise(resolve => setTimeout(resolve, 1000)); + await handleUpdate('species', 'species', 'SpeciesID', speciesID); + }; + + const handleFinalConfirm = () => { + handleSave(newRow); + }; + + useEffect(() => { + if (beginUpload) handleBeginUpload(); + }, [beginUpload]); + + useEffect(() => { + console.log('use effect upload status: ', uploadStatus); + if (Object.values(uploadStatus).every(value => value === 'completed')) setIsConfirmStep(true); + }, [uploadStatus]); + + // pulled from JoyUI doc example + function IconStepper() { + const steps = Object.keys(uploadStatus); + return ( + + {steps.map((stepKey, index) => { + const status = uploadStatus[stepKey as keyof typeof uploadStatus]; + + // Determine the step's state + const isCompleted = status === 'completed'; + const isActive = status === 'in-progress'; + const isDisabled = status === 'idle'; + + return ( + + {stepIcons[index]} + + } + > + + {stepKey === 'coremeasurements' ? 'CoreMeasurements' : stepKey.charAt(0).toUpperCase() + stepKey.slice(1)} + + + ); + })} + + ); + } + + return ( + + + Data Editing + + In order to make changes to this data set, cascading changes must be made across the schema. + {!beginUpload && !isConfirmStep && ( + Press the Begin button to begin this process, or Cancel to revert your changes. + )} + {beginUpload && !isConfirmStep && ( + <> + + Beginning upload... + + {IconStepper()} + + )} + + + {!beginUpload && !isConfirmStep && ( + <> + + + + )} + {isConfirmStep && ( + <> + + + )} + + + + ); +} diff --git a/frontend/components/datagrids/measurementscommons.tsx b/frontend/components/datagrids/measurementscommons.tsx index 7e074a4b..bff77631 100644 --- a/frontend/components/datagrids/measurementscommons.tsx +++ b/frontend/components/datagrids/measurementscommons.tsx @@ -88,7 +88,7 @@ import { MeasurementsSummaryResult } from '@/config/sqlrdsdefinitions/views'; import Divider from '@mui/joy/Divider'; import PictureAsPdfIcon from '@mui/icons-material/PictureAsPdf'; import GridOnIcon from '@mui/icons-material/GridOn'; -import SkipReEnterDataModal from '@/components/datagrids/skipreentrydatamodal'; +import MSVEditingModal from '@/components/datagrids/applications/msveditingmodal'; function debounce void>(fn: T, delay: number): T { let timeoutId: ReturnType; @@ -1578,8 +1578,14 @@ export default function MeasurementsCommons(props: Readonly )} - {isDialogOpen && promiseArguments && ( - + {isDialogOpen && promiseArguments && !promiseArguments.oldRow.isNew && ( + )} {isDeleteDialogOpen && ( >(original: T, upd return changes; } + +export function mysqlEscape(value: any): string { + if (value === null || value === undefined) return 'NULL'; + if (typeof value === 'number') return value.toString(); + if (typeof value === 'boolean') return value ? 'TRUE' : 'FALSE'; + if (typeof value === 'string') return `'${value.replace(/'/g, "''")}'`; + throw new Error('Unsupported value type'); +} + +export function formatQuery(query: string, values: any[]): string { + let valueIndex = 0; // Pointer for values + return query + .replace(/\?\?/g, () => { + const identifier = values[valueIndex++]; + if (Array.isArray(identifier) && identifier.length === 2) { + // If identifier is [schema, table], format it as `schema`.`table` + const [schema, table] = identifier; + return `\`${schema.replace(/`/g, '``')}\`.\`${table.replace(/`/g, '``')}\``; + } else if (typeof identifier === 'string') { + // Single string identifier + return `\`${identifier.replace(/`/g, '``')}\``; + } else { + throw new Error(`Invalid identifier for ?? placeholder: ${JSON.stringify(identifier)}`); + } + }) + .replace(/\?/g, () => { + const value = values[valueIndex++]; + if (value === null || value === undefined) return 'NULL'; + if (typeof value === 'number') return value.toString(); + if (typeof value === 'boolean') return value ? 'TRUE' : 'FALSE'; + if (typeof value === 'object' && !Array.isArray(value)) { + return Object.entries(value) + .map(([key, val]) => `\`${key}\` = ${mysqlEscape(val)}`) + .join(', '); + } + if (typeof value === 'string') return `'${value.replace(/'/g, "''")}'`; + throw new Error(`Unsupported value type: ${typeof value}`); + }); +}