Skip to content

Commit

Permalink
SIMSBIOHUB-587 UI: Multi CSV Upload Page (#1357)
Browse files Browse the repository at this point in the history
- UI components for multi CSV import (Captures / Measurements / Markings)
  • Loading branch information
MacQSL authored Sep 10, 2024
1 parent c6e2f46 commit 407efe1
Show file tree
Hide file tree
Showing 17 changed files with 873 additions and 97 deletions.
32 changes: 30 additions & 2 deletions api/src/services/import-services/import-csv.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
});
Expand All @@ -45,6 +47,7 @@ describe('importCSV', () => {
};

sinon.stub(worksheetUtils, 'validateCsvFile').returns(false);
sinon.stub(worksheetUtils, 'getWorksheetRowObjects').returns([{ ID: '1' }]);

try {
await importCSV(mockCsv, importer);
Expand Down Expand Up @@ -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<any> = {
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.`);
}
});
});
6 changes: 5 additions & 1 deletion api/src/services/import-services/import-csv.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,13 +49,17 @@ export const importCSV = async <ValidatedRow, InsertReturn>(
// 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'
]);
Expand Down
2 changes: 2 additions & 0 deletions api/src/utils/logger.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down
10 changes: 9 additions & 1 deletion api/src/utils/logger.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
})
);

Expand Down
71 changes: 13 additions & 58 deletions app/src/components/file-upload/FileUploadItem.tsx
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -283,56 +276,18 @@ const FileUploadItem = (props: IFileUploadItemProps) => {
}, [initiateCancel, isSafeToCancel, props]);

return (
<ListItem
key={file.name}
secondaryAction={<MemoizedActionButton status={status} onCancel={() => 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]
}
}}>
<ListItemIcon>
<Icon path={mdiFileOutline} size={1.25} style={error ? { color: 'error.main' } : { color: 'text.secondary' }} />
</ListItemIcon>
<ListItemText
primary={file.name}
secondary={<Subtext file={file} status={status} progress={progress} error={error} />}
sx={{
'& .MuiListItemText-primary': {
fontWeight: 700
}
}}></ListItemText>

<Box
sx={{
ml: 5,
width: '100%',
'& .MuiLinearProgress-root': {
mb: 1
}
}}>
<MemoizedProgressBar status={status} progress={progress} />
</Box>
{props.enableErrorDetails && (
<Box sx={{ mt: 1, ml: 5, width: '100%' }}>
<FileUploadItemErrorDetails error={error} errorDetails={errorDetails} />
</Box>
)}
</ListItem>
<FileUploadItemContent
file={file}
status={status}
progress={progress}
error={error}
errorDetails={errorDetails}
enableErrorDetails={props.enableErrorDetails}
onCancel={() => setInitiateCancel(true)}
SubtextComponent={Subtext}
ActionButtonComponent={MemoizedActionButton as any}
ProgressBarComponent={MemoizedProgressBar as any}
/>
);
};

Expand Down
110 changes: 110 additions & 0 deletions app/src/components/file-upload/FileUploadItemContent.tsx
Original file line number Diff line number Diff line change
@@ -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<IFileUploadItemProps, 'uploadHandler' | 'onSuccess' | 'fileHandler'> & {
/**
* 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 (
<ListItem
key={props.file.name}
secondaryAction={<ActionButton status={props.status} onCancel={props.onCancel} />}
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]
}
}}>
<ListItemIcon>
<Icon
path={mdiFileOutline}
size={1.25}
style={props.error ? { color: 'error.main' } : { color: 'text.secondary' }}
/>
</ListItemIcon>
<ListItemText
primary={props.file.name}
secondary={<Subtext file={props.file} status={props.status} progress={props.progress} error={props.error} />}
sx={{
'& .MuiListItemText-primary': {
fontWeight: 700
}
}}
/>

<Box
sx={{
ml: 5,
width: '100%',
'& .MuiLinearProgress-root': {
mb: 1
}
}}>
<ProgressBar status={props.status} progress={props.progress} />
</Box>
{props.enableErrorDetails && (
<Box sx={{ mt: 1, ml: 5, width: '100%' }}>
<FileUploadItemErrorDetails error={props.error} errorDetails={props.errorDetails} />
</Box>
)}
</ListItem>
);
};
Loading

0 comments on commit 407efe1

Please sign in to comment.