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

WAITP-1330: File upload question type #5100

Merged
merged 15 commits into from
Nov 5, 2023
Merged
17 changes: 13 additions & 4 deletions packages/central-server/src/dataAccessors/upsertAnswers.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
* Tupaia
* Copyright (c) 2017 - 2023 Beyond Essential Systems Pty Ltd
*/

import { QuestionType } from '@tupaia/types';
import {
DatabaseError,
getS3ImageFilePath,
Expand All @@ -22,21 +22,30 @@ export async function upsertAnswers(models, answers, surveyResponseId) {
question_id: answer.question_id,
survey_response_id: surveyResponseId,
};

if (answer.type === 'Photo') {
const s3Client = new S3Client(new S3());
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a good chance to move the handling of files and images from meditrak-app-server to central server and re-use that. It looks pretty much the same but will require a bit of refactoring on the meditrak-app-server side. This might be a good task to pair on with @rohan-bes

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Did you wanna have a pair on this @acdunham? I think there'd be some time next week if you wanted 👍

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sure, sounds good @rohan-bes, I will make a calendar invite :)

if (answer.type === QuestionType.Photo) {
const validFileIdRegex = RegExp('^[a-f\\d]{24}$');
if (validFileIdRegex.test(answer.body)) {
// if this is passed a valid id in the answer body
answerDocument.text = `${S3_BUCKET_PATH}${getS3ImageFilePath()}${answer.body}.png`;
} else {
// included for backwards compatibility passing base64 strings for images, and for datatrak-web to upload images in answers
try {
const s3Client = new S3Client(new S3());
answerDocument.text = await s3Client.uploadImage(answer.body);
} catch (error) {
throw new UploadError(error);
}
}
// if the answer is a file object, upload it to s3 and save the url as the answer. If it's not a file object that means it is just a url to a file, which will be handled by default
} else if (
answer.type === QuestionType.File &&
answer.body?.hasOwnProperty('name') &&
answer.body?.hasOwnProperty('value')
) {
answerDocument.text = await s3Client.uploadFile(
`${surveyResponseId}_${answer.question_id}_${answer?.body?.name}`,
answer.body.value,
);
} else {
answerDocument.text = answer.body;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,6 @@ export const processSurveyResponse = async (
};

// Handle special question types
// TODO: file upload handling
switch (type) {
// format dates to be ISO strings
case QuestionType.SubmissionDate:
Expand Down
2 changes: 1 addition & 1 deletion packages/datatrak-web/src/api/mutations/useSubmitSurvey.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ export const useSubmitSurvey = () => {
const surveyResponseData = useSurveyResponseData();

return useMutation<any, Error, AnswersT, unknown>(
async (answers: AnswersT) => {
async (answers: AnswersT) => {
if (!answers) {
return;
}
Expand Down
92 changes: 92 additions & 0 deletions packages/datatrak-web/src/features/Questions/FileQuestion.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
/*
* Tupaia
* Copyright (c) 2017 - 2023 Beyond Essential Systems Pty Ltd
*/

import React from 'react';
import styled from 'styled-components';
import { IconButton } from '@material-ui/core';
import { Close } from '@material-ui/icons';
import { FileUploadField } from '@tupaia/ui-components';
import { SurveyQuestionInputProps } from '../../types';
import { QuestionHelperText } from './QuestionHelperText';

const MAX_FILE_SIZE_BYTES = 20 * 1024 * 1024; // 20 MB

const Wrapper = styled.div`
display: flex;
align-items: flex-end;
label {
display: flex;
flex-direction: column;
}
.MuiBox-root {
margin-top: 0.5rem;
order: 2; // put the helper text above the input
span {
font-size: 0.875rem;
}
}
`;

const ClearButton = styled(IconButton)`
padding: 0.5rem;
margin-left: 0.5rem;
`;

type Base64 = string | null | ArrayBuffer;

const createEncodedFile = (fileObject: File): Promise<Base64> => {
return new Promise((resolve, reject) => {
const reader = new FileReader();

reader.onload = () => {
resolve(reader.result);
};

reader.onerror = reject;

reader.readAsDataURL(fileObject);
});
};

export const FileQuestion = ({
label,
required,
detailLabel,
controllerProps: { onChange, value: selectedFile, name },
}: SurveyQuestionInputProps) => {
const handleChange = async (_e, _name, files) => {
const file = files[0];
const encodedFile = await createEncodedFile(file);
// convert to an object with an encoded file so that it can be handled in the backend and uploaded to s3
onChange({
name: file.name,
value: encodedFile,
});
};

const handleClearFile = () => {
onChange(null);
};
return (
<Wrapper>
<FileUploadField
name={name}
fileName={selectedFile?.name}
onChange={handleChange}
label={label!}
helperText={detailLabel!}
maxSizeInBytes={MAX_FILE_SIZE_BYTES}
showFileSize
FormHelperTextComponent={QuestionHelperText}
required={required}
/>
{selectedFile?.value && (
<ClearButton title="Clear file" onClick={handleClearFile}>
<Close />
</ClearButton>
)}
</Wrapper>
);
};
1 change: 1 addition & 0 deletions packages/datatrak-web/src/features/Questions/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,3 +14,4 @@ export { EntityQuestion } from './EntityQuestion';
export { AutocompleteQuestion } from './AutocompleteQuestion';
export { ReadOnlyQuestion } from './ReadOnlyQuestion';
export { PhotoQuestion } from './PhotoQuestion';
export { FileQuestion } from './FileQuestion';
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import {
AutocompleteQuestion,
ReadOnlyQuestion,
PhotoQuestion,
FileQuestion,
} from '../../Questions';
import { SurveyQuestionFieldProps } from '../../../types';
import { useSurveyForm } from '..';
Expand Down Expand Up @@ -67,6 +68,7 @@ export enum QUESTION_TYPES {
CodeGenerator = ReadOnlyQuestion,
Arithmetic = ReadOnlyQuestion,
Condition = ReadOnlyQuestion,
File = FileQuestion,
}

const getNameForController = (name, type) => {
Expand Down
32 changes: 19 additions & 13 deletions packages/ui-components/src/components/Inputs/FileUploadField.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,12 +12,7 @@ import { SaveAlt } from '../Icons';
import { InputLabel } from './InputLabel';

const HiddenFileInput = styled.input`
width: 0.1px;
height: 0.1px;
opacity: 0;
overflow: hidden;
position: absolute;
z-index: -1;
display: none; // Hide the input element without applying other styles - setting it to be small and position absolute causes the form to crash when the input is clicked
`;

const FileNameAndFileSize = styled.span`
Expand All @@ -44,13 +39,15 @@ interface FileUploadFieldProps {
) => void;
name: string;
fileName: string;
multiple: boolean;
textOnButton: string;
multiple?: boolean;
textOnButton?: string;
label?: string;
tooltip?: string;
helperText?: string;
showFileSize?: boolean;
maxSizeInBytes?: number;
FormHelperTextComponent?: React.ElementType;
required?: boolean;
}

export const FileUploadField = ({
Expand All @@ -64,6 +61,8 @@ export const FileUploadField = ({
helperText,
showFileSize = false,
maxSizeInBytes,
FormHelperTextComponent = 'p',
required,
}: FileUploadFieldProps) => {
const inputEl = useRef<HTMLInputElement | null>(null);
const text = textOnButton || `Choose file${multiple ? 's' : ''}`;
Expand All @@ -89,9 +88,9 @@ export const FileUploadField = ({
if (newSizeInBytes > maxSizeInBytes) {
setSizeInBytes(null);
setError(
`Error: file is too large: ${humanFileSize(newSizeInBytes)}. Max file size: ${humanFileSize(
maxSizeInBytes,
)}`,
`Error: file is too large: ${humanFileSize(
newSizeInBytes,
)}. Max file size: ${humanFileSize(maxSizeInBytes)}`,
);
onChange(event, undefined, null);
return;
Expand Down Expand Up @@ -122,14 +121,21 @@ export const FileUploadField = ({
onChange={handleChange}
value=""
multiple={multiple}
required={required}
/>
<GreyButton component="span" startIcon={<SaveAlt />}>
{text}
</GreyButton>
{fileName && <FileNameAndFileSize>{fileName} {showFileSize && sizeInBytes && `(${humanFileSize(sizeInBytes)})`}</FileNameAndFileSize>}
{fileName && (
<FileNameAndFileSize>
{fileName} {showFileSize && sizeInBytes && `(${humanFileSize(sizeInBytes)})`}
</FileNameAndFileSize>
)}
</FileUploadContainer>
{error && <MuiFormHelperText error>{error}</MuiFormHelperText>}
{helperText && <MuiFormHelperText>{helperText}</MuiFormHelperText>}
{helperText && (
<MuiFormHelperText component={FormHelperTextComponent}>{helperText}</MuiFormHelperText>
)}
</FileUploadWrapper>
);
};
11 changes: 9 additions & 2 deletions packages/ui-components/src/components/Inputs/InputLabel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -52,14 +52,21 @@ interface InputLabelProps {
tooltip?: string;
as?: string | ComponentType<any>;
className?: string;
htmlFor?: string;
}

export const InputLabel = ({ label, tooltip, as = 'label', className }: InputLabelProps) => {
export const InputLabel = ({
label,
tooltip,
as = 'label',
className,
htmlFor,
}: InputLabelProps) => {
// If no label, don't render anything, so there isn't an empty label tag in the DOM
if (!label) return null;
return (
// allows us to pass in a custom element to render as, e.g. a span if it is going to be contained in a label element, for example when using MUI's TextField component. Otherwise defaults to a label element so that it can be a standalone label
<LabelWrapper as={as} className={className}>
<LabelWrapper as={as} className={className} htmlFor={htmlFor}>
{label}
{tooltip && (
<Tooltip title={tooltip} placement="top">
Expand Down
Loading