From 0bee76dd8d4b1b35bb0982018a2357a2d4ebc5c6 Mon Sep 17 00:00:00 2001 From: Mac Deluca Date: Sun, 22 Dec 2024 23:16:49 -0800 Subject: [PATCH] feat: created component for rendering single CSV import dialogs --- .../components/csv/CSVSingleImportDialog.tsx | 149 ++++++++++++++++++ .../import-captures/utils/templates.ts | 23 ++- .../table/TelemetryTableContainer.tsx | 138 ++-------------- app/src/hooks/api/useTelemetryApi.ts | 4 +- app/src/utils/csv-utils.ts | 7 +- 5 files changed, 187 insertions(+), 134 deletions(-) create mode 100644 app/src/components/csv/CSVSingleImportDialog.tsx diff --git a/app/src/components/csv/CSVSingleImportDialog.tsx b/app/src/components/csv/CSVSingleImportDialog.tsx new file mode 100644 index 0000000000..e46fea4af9 --- /dev/null +++ b/app/src/components/csv/CSVSingleImportDialog.tsx @@ -0,0 +1,149 @@ +import LoadingButton from '@mui/lab/LoadingButton/LoadingButton'; +import { Box, Dialog, DialogActions, DialogContent, Divider, Typography } from '@mui/material'; +import { AxiosProgressEvent } from 'axios'; +import { UploadFileStatus } from 'components/file-upload/FileUploadItem'; +import { FileUploadSingleItem } from 'components/file-upload/FileUploadSingleItem'; +import { DialogContext } from 'contexts/dialogContext'; +import { useContext, useState } from 'react'; +import { isCSVValidationError } from 'utils/csv-utils'; +import { getAxiosProgress } from 'utils/Utils'; +import { CSVDropzoneSection } from './CSVDropzoneSection'; + +interface CSVSingleImportDialogProps { + open: boolean; + dialogTitle: string; + dialogSummary: string; + onCancel: () => void; + onImport: (file: File, onProgress: (progressEvent: AxiosProgressEvent) => void) => Promise; + onDownloadTemplate: () => void; +} + +/** + * Dialog for importing a single CSV file. + * + * @param {CSVSingleImportDialogProps} props + * @return {*} {JSX.Element} + */ +export const CSVSingleImportDialog = (props: CSVSingleImportDialogProps) => { + const dialogContext = useContext(DialogContext); + + // Dialog and import state + const [file, setFile] = useState(null); + const [uploadStatus, setUploadStatus] = useState(UploadFileStatus.STAGED); + const [progress, setProgress] = useState(0); + const [error, setError] = useState(null); + + const isUploading = uploadStatus === UploadFileStatus.UPLOADING || uploadStatus === UploadFileStatus.FINISHING_UPLOAD; + const disableImportButton = + isUploading || !file || uploadStatus === UploadFileStatus.FAILED || uploadStatus === UploadFileStatus.COMPLETE; + + /** + * Cancel the dialog and reset the file import state + * + * @returns {void} + */ + const handleCancel = (): void => { + props.onCancel(); + handleResetFileImport(); + }; + + /** + * Reset the file import state, independent of the dialog state + * + * @returns {void} + */ + const handleResetFileImport = (): void => { + setFile(null); + setUploadStatus(UploadFileStatus.STAGED); + setProgress(0); + setError(null); + }; + + /** + * Import the CSV file and update the status accordingly + * + * @param {File | null} file The CSV file to import + * @returns {Promise} + */ + const handleCSVFileImport = async (file: File | null): Promise => { + if (!file) { + return; + } + + try { + setUploadStatus(UploadFileStatus.UPLOADING); + + await props.onImport(file, (progressEvent) => { + // Update the progress state from the Axios progress event + setProgress(getAxiosProgress(progressEvent)); + + if (progressEvent.loaded === progressEvent.total) { + setUploadStatus(UploadFileStatus.FINISHING_UPLOAD); + } + }); + + setUploadStatus(UploadFileStatus.COMPLETE); + + // Show a success snackbar message + dialogContext.setSnackbar({ + open: true, + snackbarMessage: ( + + CSV imported successfully. + + ) + }); + } catch (err) { + if (err instanceof Error) { + setError(err); + } + + setUploadStatus(UploadFileStatus.FAILED); + } + }; + + if (!props.open) { + return null; + } + + return ( + + + + + setError(new Error(message))} + progress={progress} + onFile={(file) => setFile(file)} + onCancel={handleResetFileImport} + /> + + + + + + + { + handleCSVFileImport(file); + }} + color="primary" + variant="contained" + disabled={disableImportButton}> + Import + + + + {uploadStatus === UploadFileStatus.COMPLETE ? 'Close' : 'Cancel'} + + + + ); +}; diff --git a/app/src/features/surveys/animals/profile/captures/import-captures/utils/templates.ts b/app/src/features/surveys/animals/profile/captures/import-captures/utils/templates.ts index e30e125a2f..2b9188ceef 100644 --- a/app/src/features/surveys/animals/profile/captures/import-captures/utils/templates.ts +++ b/app/src/features/surveys/animals/profile/captures/import-captures/utils/templates.ts @@ -1,20 +1,20 @@ -import { getCSVTemplate } from 'utils/csv-utils'; +import { CSVTemplateString, getCSVTemplate } from 'utils/csv-utils'; /** * Get CSV template for measurements. * - * @returns {string} Encoded CSV template + * @returns {CSVTemplateString} Encoded CSV template */ -export const getMeasurementsCSVTemplate = (): string => { +export const getMeasurementsCSVTemplate = (): CSVTemplateString => { return getCSVTemplate(['ALIAS', 'CAPTURE_DATE', 'CAPTURE_TIME']); }; /** * Get CSV template for captures. * - * @returns {string} Encoded CSV template + * @returns {CSVTemplateString} Encoded CSV template */ -export const getCapturesCSVTemplate = (): string => { +export const getCapturesCSVTemplate = (): CSVTemplateString => { return getCSVTemplate([ 'ALIAS', 'CAPTURE_DATE', @@ -33,9 +33,9 @@ export const getCapturesCSVTemplate = (): string => { /** * Get CSV template for markings. * - * @returns {string} Encoded CSV template + * @returns {CSVTemplateString} Encoded CSV template */ -export const getMarkingsCSVTemplate = (): string => { +export const getMarkingsCSVTemplate = (): CSVTemplateString => { return getCSVTemplate([ 'ALIAS', 'CAPTURE_DATE', @@ -48,3 +48,12 @@ export const getMarkingsCSVTemplate = (): string => { 'COMMENT' ]); }; + +/** + * Get CSV template for telemetry. + * + * @returns {CSVTemplateString} Encoded CSV template + */ +export const getTelemetryCSVTemplate = (): CSVTemplateString => { + return getCSVTemplate(['VENDOR', 'SERIAL', 'LATITUDE', 'LONGITUDE', 'DATE', 'TIME']); +}; diff --git a/app/src/features/surveys/telemetry/table/TelemetryTableContainer.tsx b/app/src/features/surveys/telemetry/table/TelemetryTableContainer.tsx index 1989cc7286..caa3c1084e 100644 --- a/app/src/features/surveys/telemetry/table/TelemetryTableContainer.tsx +++ b/app/src/features/surveys/telemetry/table/TelemetryTableContainer.tsx @@ -16,27 +16,23 @@ import Stack from '@mui/material/Stack'; import Toolbar from '@mui/material/Toolbar'; import Typography from '@mui/material/Typography'; import axios, { AxiosProgressEvent } from 'axios'; -import { CSVErrorsTableContainer } from 'components/csv/CSVErrorsTableContainer'; +import { CSVSingleImportDialog } from 'components/csv/CSVSingleImportDialog'; import DataGridValidationAlert from 'components/data-grid/DataGridValidationAlert'; import YesNoDialog from 'components/dialog/YesNoDialog'; -import { UploadFileStatus } from 'components/file-upload/FileUploadItem'; -import { FileUploadSingleItem } from 'components/file-upload/FileUploadSingleItem'; import { TelemetryTableI18N } from 'constants/i18n'; -import { DialogContext, ISnackbarProps } from 'contexts/dialogContext'; import { SurveyContext } from 'contexts/surveyContext'; +import { getTelemetryCSVTemplate } from 'features/surveys/animals/profile/captures/import-captures/utils/templates'; import { TelemetryDeviceKeysButton } from 'features/surveys/telemetry/manage/device-keys/TelemetryDeviceKeysButton'; import { TelemetryTable } from 'features/surveys/telemetry/table/TelemetryTable'; -import { APIError } from 'hooks/api/useAxios'; import { useBiohubApi } from 'hooks/useBioHubApi'; import { useTelemetryTableContext } from 'hooks/useContext'; import { useContext, useDeferredValue, useState } from 'react'; -import { CSVError, isCSVValidationError } from 'utils/csv-utils'; -import { getAxiosProgress } from 'utils/Utils'; +import { downloadFile } from 'utils/file-utils'; export const TelemetryTableContainer = () => { const biohubApi = useBiohubApi(); - const dialogContext = useContext(DialogContext); + //const dialogContext = useContext(DialogContext); const telemetryTableContext = useTelemetryTableContext(); const surveyContext = useContext(SurveyContext); @@ -47,23 +43,13 @@ export const TelemetryTableContainer = () => { // Telemetry import dialog state const [showImportDialog, setShowImportDialog] = useState(false); - const [importCSVErrors, setImportCSVErrors] = useState([]); - const [file, setFile] = useState(null); - const [uploadStatus, setUploadStatus] = useState(UploadFileStatus.STAGED); - const [progress, setProgress] = useState(0); - const isUploading = uploadStatus === UploadFileStatus.UPLOADING || uploadStatus === UploadFileStatus.FINISHING_UPLOAD; - const disableImportButton = isUploading || !file || uploadStatus === UploadFileStatus.FAILED; const cancelToken = axios.CancelToken.source(); const deferredUnsavedChanges = useDeferredValue(telemetryTableContext.hasUnsavedChanges); const numSelectedRows = telemetryTableContext.rowSelectionModel.length; - const showSnackBar = (textDialogProps?: Partial) => { - dialogContext.setSnackbar({ ...textDialogProps, open: true }); - }; - const handleCloseContextMenu = () => { setContextMenuAnchorEl(null); }; @@ -72,128 +58,36 @@ export const TelemetryTableContainer = () => { setColumnVisibilityMenuAnchorEl(null); }; - const handleResetFileImport = () => { - setFile(null); - setImportCSVErrors([]); - setUploadStatus(UploadFileStatus.STAGED); - setProgress(0); - }; - - /** - * Handle the close of the import dialog. - * - * @returns {*} {void} - */ - const handleCloseImportDialog = () => { - setShowImportDialog(false); - handleResetFileImport(); - }; - - /** - * Handle the import of telemetry data. - * - * Note: This will render a table in the dialog if CSVErrors are present - * @param {File} file - * @returns {*} {void} - */ - const handleImportTelemetry = async (file: File) => { + const handleImportTelemetryCSV = async (file: File, onProgress: (progressEvent: AxiosProgressEvent) => void) => { try { - setUploadStatus(UploadFileStatus.UPLOADING); - await biohubApi.telemetry.importManualTelemetryCSV( surveyContext.projectId, surveyContext.surveyId, file, cancelToken, - async (progressEvent: AxiosProgressEvent) => { - setProgress(getAxiosProgress(progressEvent)); - if (progressEvent.loaded === progressEvent.total) { - setUploadStatus(UploadFileStatus.FINISHING_UPLOAD); - } - } + onProgress ); - setShowImportDialog(false); - setProcessingRecords(true); - showSnackBar({ - snackbarMessage: ( - - Telemetry imported successfully. - - ) - }); - - telemetryTableContext.refreshRecords().then(() => { - setProcessingRecords(false); - }); - setUploadStatus(UploadFileStatus.COMPLETE); - } catch (error) { - setUploadStatus(UploadFileStatus.FAILED); - - if (isCSVValidationError(error)) { - setImportCSVErrors(error.errors); - return; - } - - const apiError = error as APIError; - - dialogContext.setErrorDialog({ - dialogTitle: TelemetryTableI18N.importRecordsErrorDialogTitle, - dialogText: TelemetryTableI18N.importRecordsErrorDialogText, - dialogError: apiError.message, - dialogErrorDetails: apiError.errors, - open: true, - onClose: () => { - setProcessingRecords(false); - dialogContext.setErrorDialog({ open: false }); - }, - onOk: () => { - setProcessingRecords(false); - dialogContext.setErrorDialog({ open: false }); - } - }); + telemetryTableContext.refreshRecords(); + } finally { + setProcessingRecords(false); } }; return ( <> - { - if (file) { - handleImportTelemetry(file); - } - }} - dialogContent={ - <> - setFile(file)} - onCancel={handleResetFileImport} - /> - {importCSVErrors.length > 0 ? ( - - - - ) : null} - + dialogTitle="Import Telemetry CSV" + dialogSummary="Import a CSV file containing telemetry records" + onCancel={() => setShowImportDialog(false)} + onImport={handleImportTelemetryCSV} + onDownloadTemplate={() => + downloadFile(getTelemetryCSVTemplate(), `SIMS-telemetry-template-${new Date().getFullYear()}.csv`) } - yesButtonProps={{ - loading: isUploading, - disabled: disableImportButton - }} /> - { * Bulk create Manual Telemetry records. * * @param {number} projectId - * @param {number} surveyIdF + * @param {number} surveyId * @param {ICreateManualTelemetry[]} manualTelemetry Manual Telemetry create objects * @return {*} {Promise} */ @@ -114,8 +114,6 @@ const useTelemetryApi = (axios: AxiosInstance) => { await axios.post(`/api/project/${projectId}/survey/${surveyId}/deployments/telemetry/manual`, { telemetry: manualTelemetry }); - - return; }; /** diff --git a/app/src/utils/csv-utils.ts b/app/src/utils/csv-utils.ts index 953548f3bb..b27956395e 100644 --- a/app/src/utils/csv-utils.ts +++ b/app/src/utils/csv-utils.ts @@ -16,13 +16,16 @@ export interface CSVValidationError { errors: CSVError[]; } +// Type alias for a CSV template +export type CSVTemplateString = string; + /** * Get CSV template from a list of column headers. * * @param {string[]} headers - CSV column headers - * @returns {string} Encoded CSV template + * @returns {CSVTemplateString} Encoded CSV template */ -export const getCSVTemplate = (headers: string[]) => { +export const getCSVTemplate = (headers: string[]): CSVTemplateString => { return 'data:text/csv;charset=utf-8,' + headers.join(',') + '\n'; };