diff --git a/api/src/services/import-services/import-csv.test.ts b/api/src/services/import-services/import-csv.test.ts index 66955e21ee..81a957e429 100644 --- a/api/src/services/import-services/import-csv.test.ts +++ b/api/src/services/import-services/import-csv.test.ts @@ -26,11 +26,13 @@ describe('importCSV', () => { const getWorksheetStub = sinon.stub(worksheetUtils, 'getDefaultWorksheet').returns(mockWorksheet); const validateCsvFileStub = sinon.stub(worksheetUtils, 'validateCsvFile').returns(true); + const getWorksheetRowsStub = sinon.stub(worksheetUtils, 'getWorksheetRowObjects').returns([{ ID: '1' }]); const data = await importCSV(mockCsv, importer); expect(getWorksheetStub).to.have.been.called.calledOnceWithExactly(worksheetUtils.constructXLSXWorkbook(mockCsv)); expect(validateCsvFileStub).to.have.been.called.calledOnceWithExactly(mockWorksheet, importer.columnValidator); + expect(getWorksheetRowsStub).to.have.been.called.calledOnceWithExactly(mockWorksheet); expect(importer.insert).to.have.been.called.calledOnceWithExactly(true); expect(data).to.be.true; }); @@ -45,6 +47,7 @@ describe('importCSV', () => { }; sinon.stub(worksheetUtils, 'validateCsvFile').returns(false); + sinon.stub(worksheetUtils, 'getWorksheetRowObjects').returns([{ ID: '1' }]); try { await importCSV(mockCsv, importer); @@ -78,16 +81,41 @@ describe('importCSV', () => { sinon.stub(worksheetUtils, 'getDefaultWorksheet').returns(mockWorksheet); sinon.stub(worksheetUtils, 'validateCsvFile').returns(true); + sinon.stub(worksheetUtils, 'getWorksheetRowObjects').returns([{ BAD_ID: '1' }]); try { await importCSV(mockCsv, importer); expect.fail(); } catch (err: any) { - expect(importer.validateRows).to.have.been.calledOnceWithExactly([], mockWorksheet); - expect(err.message).to.be.eql(`Failed to import Critter CSV. Column data validator failed.`); + expect(importer.validateRows).to.have.been.calledOnceWithExactly([{ BAD_ID: '1' }], mockWorksheet); + expect(err.message).to.be.eql(`Cell validator failed. Cells have invalid reference values.`); expect(err.errors[0]).to.be.eql({ csv_row_errors: mockValidation.error.issues }); } }); + + it('should throw error if CSV contains no rows', async () => { + const mockCsv = new MediaFile('file', 'file', Buffer.from('')); + const mockWorksheet = {}; + const mockValidation = { success: false, error: { issues: [{ row: 1, message: 'invalidated' }] } }; + + const importer: CSVImportStrategy = { + columnValidator: { ID: { type: 'string' } }, + validateRows: sinon.stub().returns(mockValidation), + insert: sinon.stub().resolves(true) + }; + + sinon.stub(worksheetUtils, 'getDefaultWorksheet').returns(mockWorksheet); + sinon.stub(worksheetUtils, 'validateCsvFile').returns(true); + sinon.stub(worksheetUtils, 'getWorksheetRowObjects').returns([]); + + try { + await importCSV(mockCsv, importer); + expect.fail(); + } catch (err: any) { + expect(importer.validateRows).to.not.have.been.called; + expect(err.message).to.be.eql(`Row validator failed. No rows found in the CSV file.`); + } + }); }); diff --git a/api/src/services/import-services/import-csv.ts b/api/src/services/import-services/import-csv.ts index 6cee607e8b..3fbcb9313e 100644 --- a/api/src/services/import-services/import-csv.ts +++ b/api/src/services/import-services/import-csv.ts @@ -49,13 +49,17 @@ export const importCSV = async ( // Convert the worksheet into an array of records const worksheetRows = getWorksheetRowObjects(worksheet); + if (!worksheetRows.length) { + throw new ApiGeneralError(`Row validator failed. No rows found in the CSV file.`); + } + // Validate the CSV rows with reference data const validation = await importer.validateRows(worksheetRows, worksheet); // Throw error is row validation failed and inject validation errors // The validation errors can be either custom (Validation) or Zod (SafeParseReturn) if (!validation.success) { - throw new ApiGeneralError(`Failed to import Critter CSV. Column data validator failed.`, [ + throw new ApiGeneralError(`Cell validator failed. Cells have invalid reference values.`, [ { csv_row_errors: validation.error.issues }, 'importCSV->_validate->_validateRows' ]); diff --git a/api/src/utils/logger.test.ts b/api/src/utils/logger.test.ts index 1fc7585831..e1c83987a6 100644 --- a/api/src/utils/logger.test.ts +++ b/api/src/utils/logger.test.ts @@ -27,7 +27,9 @@ describe('logger', () => { }); it('sets the log level for the console transport', () => { + //const myLogger1 = require('./logger').getLogger('myLoggerA'); const myLogger1 = getLogger('myLoggerA'); + expect(myLogger1.transports[1].level).to.equal('info'); setLogLevel('debug'); diff --git a/api/src/utils/logger.ts b/api/src/utils/logger.ts index cefe86ba19..8377cef755 100644 --- a/api/src/utils/logger.ts +++ b/api/src/utils/logger.ts @@ -75,7 +75,15 @@ export const getLogger = function (logLabel: string) { })(), winston.format.timestamp({ format: 'YYYY-MM-DD HH:mm:ss' }), winston.format.prettyPrint({ colorize: false, depth: 10 }) - ) + ), + options: { + // https://nodejs.org/api/fs.html#file-system-flags + // Open file for reading and appending. The file is created if it does not exist. + flags: 'a+', + // https://nodejs.org/api/fs.html#fs_fs_createwritestream_path_options + // Set the file mode to be readable and writable by all users. + mode: 0o666 + } }) ); diff --git a/app/src/components/file-upload/FileUploadItem.tsx b/app/src/components/file-upload/FileUploadItem.tsx index 39ac6e8dcc..8a293edf08 100644 --- a/app/src/components/file-upload/FileUploadItem.tsx +++ b/app/src/components/file-upload/FileUploadItem.tsx @@ -1,18 +1,11 @@ -import { mdiFileOutline } from '@mdi/js'; -import Icon from '@mdi/react'; -import Box from '@mui/material/Box'; -import { grey } from '@mui/material/colors'; -import ListItem from '@mui/material/ListItem'; -import ListItemIcon from '@mui/material/ListItemIcon'; -import ListItemText from '@mui/material/ListItemText'; import axios, { AxiosProgressEvent, CancelTokenSource } from 'axios'; -import FileUploadItemErrorDetails from 'components/file-upload/FileUploadItemErrorDetails'; import FileUploadItemSubtext from 'components/file-upload/FileUploadItemSubtext'; import { APIError } from 'hooks/api/useAxios'; import useIsMounted from 'hooks/useIsMounted'; import React, { useCallback, useEffect, useState } from 'react'; import { v4 } from 'uuid'; import FileUploadItemActionButton from './FileUploadItemActionButton'; +import { FileUploadItemContent } from './FileUploadItemContent'; import FileUploadItemProgressBar from './FileUploadItemProgressBar'; export enum UploadFileStatus { @@ -283,56 +276,18 @@ const FileUploadItem = (props: IFileUploadItemProps) => { }, [initiateCancel, isSafeToCancel, props]); return ( - setInitiateCancel(true)} />} - sx={{ - flexWrap: 'wrap', - borderStyle: 'solid', - borderWidth: '1px', - borderRadius: '6px', - background: grey[100], - borderColor: grey[300], - '& + li': { - mt: 1 - }, - '& .MuiListItemSecondaryAction-root': { - top: '36px' - }, - '&:last-child': { - borderBottomStyle: 'solid', - borderBottomWidth: '1px', - borderBottomColor: grey[300] - } - }}> - - - - } - sx={{ - '& .MuiListItemText-primary': { - fontWeight: 700 - } - }}> - - - - - {props.enableErrorDetails && ( - - - - )} - + setInitiateCancel(true)} + SubtextComponent={Subtext} + ActionButtonComponent={MemoizedActionButton as any} + ProgressBarComponent={MemoizedProgressBar as any} + /> ); }; diff --git a/app/src/components/file-upload/FileUploadItemContent.tsx b/app/src/components/file-upload/FileUploadItemContent.tsx new file mode 100644 index 0000000000..93a002d221 --- /dev/null +++ b/app/src/components/file-upload/FileUploadItemContent.tsx @@ -0,0 +1,110 @@ +import { mdiFileOutline } from '@mdi/js'; +import Icon from '@mdi/react'; +import Box from '@mui/material/Box'; +import { grey } from '@mui/material/colors'; +import ListItem from '@mui/material/ListItem'; +import ListItemIcon from '@mui/material/ListItemIcon'; +import ListItemText from '@mui/material/ListItemText'; +import FileUploadItemErrorDetails from 'components/file-upload/FileUploadItemErrorDetails'; +import { IFileUploadItemProps, UploadFileStatus } from './FileUploadItem'; +import FileUploadItemActionButton from './FileUploadItemActionButton'; +import FileUploadItemProgressBar from './FileUploadItemProgressBar'; +import FileUploadItemSubtext from './FileUploadItemSubtext'; + +type FileUploadItemContentProps = Omit & { + /** + * The file upload status. + * + * @type {UploadFileStatus} + * @memberof FileUploadItemContentProps + */ + status: UploadFileStatus; + /** + * The progress of the file upload. + * + * @type {number} + * @memberof FileUploadItemContentProps + */ + progress: number; + /** + * Additional error details. + * + * @type {Array<{ _id: string; message: string }>} + * @memberof FileUploadItemContentProps + */ + errorDetails?: Array<{ _id: string; message: string }>; +}; + +/** + * File upload item content. The UI layout of a file upload item. + * + * @param {FileUploadItemContentProps} props + * @returns {*} + */ +export const FileUploadItemContent = (props: FileUploadItemContentProps) => { + /** + * Sensible defaults for the subtext, action button, and progress bar components. + * + **/ + const Subtext = props.SubtextComponent ?? FileUploadItemSubtext; + const ActionButton = props.ActionButtonComponent ?? FileUploadItemActionButton; + const ProgressBar = props.ProgressBarComponent ?? FileUploadItemProgressBar; + + return ( + } + sx={{ + flexWrap: 'wrap', + borderStyle: 'solid', + borderWidth: '1px', + borderRadius: '6px', + background: grey[100], + borderColor: grey[300], + '& + li': { + mt: 1 + }, + '& .MuiListItemSecondaryAction-root': { + top: '36px' + }, + '&:last-child': { + borderBottomStyle: 'solid', + borderBottomWidth: '1px', + borderBottomColor: grey[300] + } + }}> + + + + } + sx={{ + '& .MuiListItemText-primary': { + fontWeight: 700 + } + }} + /> + + + + + {props.enableErrorDetails && ( + + + + )} + + ); +}; diff --git a/app/src/components/file-upload/FileUploadSingleItem.tsx b/app/src/components/file-upload/FileUploadSingleItem.tsx new file mode 100644 index 0000000000..5b6060e7ca --- /dev/null +++ b/app/src/components/file-upload/FileUploadSingleItem.tsx @@ -0,0 +1,133 @@ +import { grey } from '@mui/material/colors'; +import { Box } from '@mui/system'; +import DropZone, { IDropZoneConfigProps } from 'components/file-upload/DropZone'; +import { UploadFileStatus } from 'components/file-upload/FileUploadItem'; +import { FileUploadItemContent } from 'components/file-upload/FileUploadItemContent'; +import { FileRejection } from 'react-dropzone'; + +type FileUploadSingleItemProps = { + /** + * The uploaded file via the dropzone. + * + * @type {File | null} + * @memberof FileUploadSingleItemProps + */ + file: File | null; + /** + * The status of the file upload. + * + * @type {UploadFileStatus} + * @memberof FileUploadSingleItemProps + */ + status: UploadFileStatus; + /** + * The error message of the file upload. + * + * @type {string} + * @memberof FileUploadSingleItemProps + */ + error?: string; + /** + * The progress of the file upload. + * + * @type {number} + * @memberof FileUploadSingleItemProps + */ + progress?: number; + /** + * Callback when file is uploaded via the dropzone. + * + * @type {(file: File | null) => void} + * @memberof FileUploadSingleItemProps + */ + onFile: (file: File | null) => void; + /** + * Optional callback to subscribe to the internal status changes. + * + * @type {(status: UploadFileStatus) => void} + * @memberof FileUploadSingleItemProps + */ + onStatus?: (status: UploadFileStatus) => void; + /** + * Optional callback to subscribe to the internal error changes. + * + * @type {(error: string) => void} + * @memberof FileUploadSingleItemProps + */ + onError?: (error: string) => void; + /** + * Optional callback when the user cancels the upload. + * + * @type {() => void} + * @memberof FileUploadSingleItemProps + */ + onCancel?: () => void; + /** + * Subset of Dropzone configuration props. + * + * @type {Pick} + * @memberof FileUploadSingleItemProps + */ + DropZoneProps?: Pick; +}; + +/** + * `FileUploadSingleItem` a stateless component with full control of the file upload process via props. + * Implements multiple callback functions to explictly handle events during the file upload. + * + * Note: This is different than the `FileUpload` component where the state is handled internally, and + * supports multiple file uploads. + * + * @param {FileUploadSingleItemProps} props + * @returns {*} + */ +export const FileUploadSingleItem = (props: FileUploadSingleItemProps) => { + return ( + <> + {props.file ? ( + { + props.onStatus?.(UploadFileStatus.PENDING); + props.onFile(null); + props.onCancel?.(); + }} + progress={props.progress ?? 0} + /> + ) : ( + + { + if (acceptedFiles.length) { + props.onStatus?.(UploadFileStatus.STAGED); + props.onFile(acceptedFiles[0]); + } else { + props.onStatus?.(UploadFileStatus.FAILED); + props.onFile(null); + props.onError?.(rejectedFiles[0].errors[0].message); + } + }} + maxNumFiles={1} + multiple={false} + {...props.DropZoneProps} + /> + + )} + + ); +}; diff --git a/app/src/contexts/dialogContext.tsx b/app/src/contexts/dialogContext.tsx index 13a855f1f9..714117fcc7 100644 --- a/app/src/contexts/dialogContext.tsx +++ b/app/src/contexts/dialogContext.tsx @@ -55,7 +55,7 @@ export interface IDialogContext { export interface ISnackbarProps { open: boolean; - onClose: () => void; + onClose?: () => void; snackbarMessage: ReactNode; snackbarAutoCloseMs?: number; //ms } @@ -89,10 +89,7 @@ export const defaultErrorDialogProps: IErrorDialogProps = { export const defaultSnackbarProps: ISnackbarProps = { snackbarMessage: '', - open: false, - onClose: () => { - // default do nothing - } + open: false }; export const DialogContext = createContext({ @@ -128,7 +125,7 @@ export const DialogContextProvider: React.FC = (props) }; const setSnackbar = function (partialProps: Partial) { - setSnackbarProps({ ...snackbarProps, ...partialProps }); + setSnackbarProps({ onClose: () => setSnackbar({ open: false }), ...snackbarProps, ...partialProps }); }; const setErrorDialog = function (partialProps: Partial) { @@ -155,10 +152,10 @@ export const DialogContextProvider: React.FC = (props) }} open={snackbarProps.open} autoHideDuration={snackbarProps?.snackbarAutoCloseMs ?? 6000} - onClose={() => setSnackbar({ open: false })} + onClose={snackbarProps.onClose} message={snackbarProps.snackbarMessage} action={ - setSnackbar({ open: false })}> + } diff --git a/app/src/features/surveys/SurveyRouter.tsx b/app/src/features/surveys/SurveyRouter.tsx index 1ee306311c..6aac3c9c36 100644 --- a/app/src/features/surveys/SurveyRouter.tsx +++ b/app/src/features/surveys/SurveyRouter.tsx @@ -48,9 +48,11 @@ const SurveyRouter: React.FC = () => { - - - + + + + + diff --git a/app/src/features/surveys/animals/AnimalRouter.tsx b/app/src/features/surveys/animals/AnimalRouter.tsx index 6ca4d02e44..ed68c95faa 100644 --- a/app/src/features/surveys/animals/AnimalRouter.tsx +++ b/app/src/features/surveys/animals/AnimalRouter.tsx @@ -1,6 +1,5 @@ import { ProjectRoleRouteGuard } from 'components/security/RouteGuards'; import { PROJECT_PERMISSION, SYSTEM_ROLE } from 'constants/roles'; -import { DialogContextProvider } from 'contexts/dialogContext'; import { TaxonomyContextProvider } from 'contexts/taxonomyContext'; import { CreateCapturePage } from 'features/surveys/animals/profile/captures/capture-form/create/CreateCapturePage'; import { EditCapturePage } from 'features/surveys/animals/profile/captures/capture-form/edit/EditCapturePage'; @@ -13,6 +12,7 @@ import { getTitle } from 'utils/Utils'; import { CreateAnimalPage } from './animal-form/create/CreateAnimalPage'; import { EditAnimalPage } from './animal-form/edit/EditAnimalPage'; import { SurveyAnimalPage } from './AnimalPage'; +import { CreateCSVCapturesPage } from './profile/captures/import-captures/CreateCSVCapturesPage'; /** * Router for all `/admin/projects/:id/surveys/:survey_id/animals/*` pages. @@ -35,9 +35,7 @@ export const AnimalRouter: React.FC = () => { - - - + @@ -48,9 +46,18 @@ export const AnimalRouter: React.FC = () => { - - - + + + + + + + @@ -74,9 +81,7 @@ export const AnimalRouter: React.FC = () => { - - - + @@ -87,9 +92,7 @@ export const AnimalRouter: React.FC = () => { - - - + @@ -100,9 +103,7 @@ export const AnimalRouter: React.FC = () => { - - - + @@ -113,9 +114,7 @@ export const AnimalRouter: React.FC = () => { - - - + diff --git a/app/src/features/surveys/animals/list/components/AnimalListToolbar.tsx b/app/src/features/surveys/animals/list/components/AnimalListToolbar.tsx index 3b31c76861..37d95485bb 100644 --- a/app/src/features/surveys/animals/list/components/AnimalListToolbar.tsx +++ b/app/src/features/surveys/animals/list/components/AnimalListToolbar.tsx @@ -96,7 +96,8 @@ export const AnimalListToolbar = (props: IAnimaListToolbarProps) => { color="primary" onClick={() => setOpenImportDialog(true)} startIcon={} - sx={{ borderTopLeftRadius: 0, borderBottomLeftRadius: 0, '& .MuiButton-startIcon': { mx: 0 } }}> + sx={{ borderTopLeftRadius: 0, borderBottomLeftRadius: 0, '& .MuiButton-startIcon': { mx: 0 } }} + /> { const { capturesCount, onAddAnimalCapture } = props; + const surveyContext = useSurveyContext(); + return ( { variant="contained" color="primary" onClick={onAddAnimalCapture} - startIcon={}> + startIcon={} + sx={{ mr: 0.2, borderTopRightRadius: 0, borderBottomRightRadius: 0 }}> Add Capture + + + } + /> + + + + + + + + handleFileState({ fileType: 'captures', status })} + onFile={(file) => handleFileState({ fileType: 'captures', file })} + onError={(error) => handleFileState({ fileType: 'captures', error })} + onCancel={() => + handleFileState({ + fileType: 'captures', + status: UploadFileStatus.PENDING, + error: undefined, + progress: undefined + }) + } + DropZoneProps={{ acceptedFileExtensions: '.csv' }} + /> + + + + + + + + handleFileState({ fileType: 'measurements', status })} + onFile={(file) => handleFileState({ fileType: 'measurements', file })} + onError={(error) => handleFileState({ fileType: 'measurements', error })} + onCancel={() => + handleFileState({ + fileType: 'measurements', + status: UploadFileStatus.PENDING, + error: undefined, + progress: undefined + }) + } + DropZoneProps={{ acceptedFileExtensions: '.csv' }} + /> + + + + + + + + handleFileState({ fileType: 'markings', status })} + onFile={(file) => handleFileState({ fileType: 'markings', file })} + onError={(error) => handleFileState({ fileType: 'markings', error })} + onCancel={() => + handleFileState({ + fileType: 'markings', + status: UploadFileStatus.PENDING, + error: undefined, + progress: undefined + }) + } + DropZoneProps={{ acceptedFileExtensions: '.csv' }} + /> + + + + + + + + Upload + + + + + + + ); +}; 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 new file mode 100644 index 0000000000..c6101422b3 --- /dev/null +++ b/app/src/features/surveys/animals/profile/captures/import-captures/utils/templates.ts @@ -0,0 +1,50 @@ +import { getCSVTemplate } from 'utils/file-utils'; + +/** + * Get CSV template for measurements. + * + * @returns {string} Encoded CSV template + */ +export const getMeasurementsCSVTemplate = (): string => { + return getCSVTemplate(['ALIAS', 'CAPTURE_DATE', 'CAPTURE_TIME']); +}; + +/** + * Get CSV template for captures. + * + * @returns {string} Encoded CSV template + */ +export const getCapturesCSVTemplate = (): string => { + return getCSVTemplate([ + 'ALIAS', + 'CAPTURE_DATE', + 'CAPTURE_TIME', + 'CAPTURE_LATITUDE', + 'CAPTURE_LONGITUDE', + 'RELEASE_DATE', + 'RELEASE_TIME', + 'RELEASE_LATITUDE', + 'RELEASE_LONGITUDE', + 'RELEASE_COMMENT', + 'CAPTURE_COMMENT' + ]); +}; + +/** + * Get CSV template for markings. + * + * @returns {string} Encoded CSV template + */ +export const getMarkingsCSVTemplate = (): string => { + return getCSVTemplate([ + 'ALIAS', + 'CAPTURE_DATE', + 'CAPTURE_TIME', + 'BODY_LOCATION', + 'MARKING_TYPE', + 'IDENTIFIER', + 'PRIMARY_COLOUR', + 'SECONDARY_COLOUR', + 'COMMENT' + ]); +}; diff --git a/app/src/hooks/api/useSurveyApi.ts b/app/src/hooks/api/useSurveyApi.ts index a9df91cb9d..c2c9929419 100644 --- a/app/src/hooks/api/useSurveyApi.ts +++ b/app/src/hooks/api/useSurveyApi.ts @@ -587,6 +587,102 @@ const useSurveyApi = (axios: AxiosInstance) => { return data; }; + /** + * Bulk upload Captures from CSV. + * + * @async + * @param {File} file - Captures CSV. + * @param {number} projectId + * @param {number} surveyId + * @returns {Promise} + */ + const importCapturesFromCsv = async ( + file: File, + projectId: number, + surveyId: number, + cancelTokenSource?: CancelTokenSource, + onProgress?: (progressEvent: AxiosProgressEvent) => void + ): Promise<{ survey_critter_ids: number[] }> => { + const formData = new FormData(); + + formData.append('media', file); + + const { data } = await axios.post( + `/api/project/${projectId}/survey/${surveyId}/critters/captures/import`, + formData, + { + cancelToken: cancelTokenSource?.token, + onUploadProgress: onProgress + } + ); + + return data; + }; + + /** + * Bulk upload Markings from CSV. + * + * @async + * @param {File} file - Captures CSV. + * @param {number} projectId + * @param {number} surveyId + * @returns {Promise} + */ + const importMarkingsFromCsv = async ( + file: File, + projectId: number, + surveyId: number, + cancelTokenSource?: CancelTokenSource, + onProgress?: (progressEvent: AxiosProgressEvent) => void + ): Promise<{ survey_critter_ids: number[] }> => { + const formData = new FormData(); + + formData.append('media', file); + + const { data } = await axios.post( + `/api/project/${projectId}/survey/${surveyId}/critters/markings/import`, + formData, + { + cancelToken: cancelTokenSource?.token, + onUploadProgress: onProgress + } + ); + + return data; + }; + + /** + * Bulk upload Measurements from CSV. + * + * @async + * @param {File} file - Captures CSV. + * @param {number} projectId + * @param {number} surveyId + * @returns {Promise} + */ + const importMeasurementsFromCsv = async ( + file: File, + projectId: number, + surveyId: number, + cancelTokenSource?: CancelTokenSource, + onProgress?: (progressEvent: AxiosProgressEvent) => void + ): Promise<{ survey_critter_ids: number[] }> => { + const formData = new FormData(); + + formData.append('media', file); + + const { data } = await axios.post( + `/api/project/${projectId}/survey/${surveyId}/critters/measurements/import`, + formData, + { + cancelToken: cancelTokenSource?.token, + onUploadProgress: onProgress + } + ); + + return data; + }; + return { createSurvey, getSurveyForView, @@ -612,9 +708,12 @@ const useSurveyApi = (axios: AxiosInstance) => { getCritterById, updateDeployment, getCritterTelemetry, + importCrittersFromCsv, + importCapturesFromCsv, + importMarkingsFromCsv, + importMeasurementsFromCsv, endDeployment, - deleteDeployment, - importCrittersFromCsv + deleteDeployment }; }; diff --git a/app/src/utils/Utils.tsx b/app/src/utils/Utils.tsx index 994630cd65..11b7867e36 100644 --- a/app/src/utils/Utils.tsx +++ b/app/src/utils/Utils.tsx @@ -1,4 +1,5 @@ import Typography from '@mui/material/Typography'; +import { AxiosProgressEvent } from 'axios'; import { SYSTEM_IDENTITY_SOURCE } from 'constants/auth'; import { DATE_FORMAT, TIME_FORMAT } from 'constants/dateTimeFormats'; import { default as dayjs } from 'dayjs'; @@ -479,3 +480,15 @@ export const getRandomHexColor = (seed: number, min = 120, max = 180): string => * @return {*} {value is T} */ export const isDefined = (value: T | undefined | null): value is T => value !== undefined && value !== null; + +/** + * Gets the progress percentage from an Axios ProgressEvent. + * + * Note: Axios will fire a `progress event` 3 times a second. + * + * @param {AxiosProgressEvent} progressEvent - Axios progress event + * + */ +export const getAxiosProgress = (progressEvent: AxiosProgressEvent) => { + return Math.round((progressEvent.loaded / (progressEvent.total || 1)) * 100); +}; diff --git a/app/src/utils/file-utils.ts b/app/src/utils/file-utils.ts new file mode 100644 index 0000000000..6fddb9881d --- /dev/null +++ b/app/src/utils/file-utils.ts @@ -0,0 +1,24 @@ +/** + * Get CSV template from a list of column headers. + * + * @param {string[]} headers - CSV column headers + * @returns {string} Encoded CSV template + */ +export const getCSVTemplate = (headers: string[]) => { + return 'data:text/csv;charset=utf-8,' + headers.join(',') + '\n'; +}; + +/** + * Download a file client side. + * + * @param {string} fileContents - String representing the file contents + * @param {string} fileName - The name of the file to download + */ +export const downloadFile = (fileContents: string, fileName: string) => { + const encodedUri = encodeURI(fileContents); + const link = document.createElement('a'); + link.setAttribute('href', encodedUri); + link.setAttribute('download', fileName); + document.body.appendChild(link); + link.click(); +};