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
Show file tree
Hide file tree
Changes from 9 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 10 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,27 @@ 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('uniqueFileName') &&
answer.body?.hasOwnProperty('data')
) {
answerDocument.text = await s3Client.uploadFile(answer.body.uniqueFileName, answer.body.data);
} else {
answerDocument.text = answer.body;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
* Copyright (c) 2017 - 2023 Beyond Essential Systems Pty Ltd
*/
import { QuestionType } from '@tupaia/types';
import { getBrowserTimeZone } from '@tupaia/utils';
import { getBrowserTimeZone, getUniqueSurveyQuestionFileName } from '@tupaia/utils';
import { generateId } from '@tupaia/database';
import { processSurveyResponse } from '../../utils';

Expand All @@ -13,6 +13,11 @@ jest.mock('@tupaia/database', () => ({
generateId: jest.fn(() => 'theEntityId'),
}));

jest.mock('@tupaia/utils', () => ({
...jest.requireActual('@tupaia/utils'),
getUniqueSurveyQuestionFileName: jest.fn(() => 'theUniqueId'),
}));

describe('processSurveyResponse', () => {
beforeAll(() => {
jest.useFakeTimers().setSystemTime(new Date(2020, 3, 1));
Expand Down Expand Up @@ -373,4 +378,41 @@ describe('processSurveyResponse', () => {
],
});
});
it('should handle when question type is File', async () => {
const result = await processSurveyResponse(
{
...responseData,
questions: [
{
questionId: 'question1',
type: QuestionType.File,
componentNumber: 1,
text: 'question1',
screenId: 'screen1',
},
],
answers: {
question1: {
value: 'theEncodedFile',
name: 'theFileName',
},
},
},
mockGetEntity,
);

expect(result).toEqual({
...processedResponseData,
answers: [
{
question_id: 'question1',
type: QuestionType.File,
body: {
data: 'theEncodedFile',
uniqueFileName: getUniqueSurveyQuestionFileName('theFileName'),
},
},
],
});
});
});
15 changes: 13 additions & 2 deletions packages/datatrak-web-server/src/utils/processSurveyResponse.ts
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 { getBrowserTimeZone } from '@tupaia/utils';
import { getBrowserTimeZone, getUniqueSurveyQuestionFileName } from '@tupaia/utils';
import {
DatatrakWebSubmitSurveyRequest,
DatatrakWebSurveyRequest,
Expand All @@ -16,6 +16,7 @@ type ConfigT = DatatrakWebSurveyRequest.SurveyScreenComponentConfig;
type SurveyRequestT = DatatrakWebSubmitSurveyRequest.ReqBody;
type AnswerT = DatatrakWebSubmitSurveyRequest.Answer;
type AutocompleteAnswerT = DatatrakWebSubmitSurveyRequest.AutocompleteAnswer;
type FileUploadAnswerT = DatatrakWebSubmitSurveyRequest.FileUploadAnswer;

export const isUpsertEntityQuestion = (config?: ConfigT) => {
if (!config?.entity) {
Expand Down Expand Up @@ -89,7 +90,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 All @@ -105,6 +105,17 @@ export const processSurveyResponse = async (
surveyResponse.entity_id = entityId;
break;
}
case QuestionType.File: {
const { name, value } = answer as FileUploadAnswerT;
answersToSubmit.push({
...answerObject,
body: {
data: value,
uniqueFileName: getUniqueSurveyQuestionFileName(name),
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Hey @rohan-bes I decided to do this in the orchestration server, as opposed to the front-end app because we need the FE answers to be a particular shape

},
});
break;
}
case QuestionType.Autocomplete: {
// if the answer is a new option, add it to the options_created array to be added to the DB
const { isNew, value, label, optionSetId } = answer as AutocompleteAnswerT;
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
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,9 @@ import { EntityType } from '../../models';
type Id = string;

type AnswerType = {
id: Id;
id?: Id;
type: string;
body: string;
body: string | Record<string, unknown>;
/**
* @checkIdExists { "table": "question" }
*/
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,19 @@ export type AutocompleteAnswer = {
label: string;
};

export type Answer = string | number | boolean | null | undefined | AutocompleteAnswer;
export type FileUploadAnswer = {
name: string;
value: string;
};

export type Answer =
| string
| number
| boolean
| null
| undefined
| AutocompleteAnswer
| FileUploadAnswer;

export type Answers = Record<string, Answer>;

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>
);
};
Loading
Loading