Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

SIMSBIOHUB-587 UI: Multi CSV Upload Page #1357

Merged
merged 12 commits into from
Sep 10, 2024
Merged
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