Skip to content

Commit

Permalink
WAITP-1330: File upload question type (#5100)
Browse files Browse the repository at this point in the history
* WIP

* Use built in functionality

* Add comment

* Fix build errors

* Working file upload

* Fix build

* Remove unused vars

* Update format of file upload answers

* Update schemas.ts

* Update processSurveyResponse.test.ts

* Update upsertAnswers.js

---------

Co-authored-by: Tom Caiger <caigertom@gmail.com>
  • Loading branch information
alexd-bes and tcaiger authored Nov 5, 2023
1 parent d308054 commit 7f12be1
Show file tree
Hide file tree
Showing 16 changed files with 283 additions and 31 deletions.
30 changes: 25 additions & 5 deletions packages/central-server/src/dataAccessors/upsertAnswers.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,15 @@
* Tupaia
* Copyright (c) 2017 - 2023 Beyond Essential Systems Pty Ltd
*/

import { DatabaseError, UploadError } from '@tupaia/utils';
import { getS3ImageFilePath, S3Client, S3, S3_BUCKET_PATH } from '@tupaia/server-utils';
import { QuestionType } from '@tupaia/types';
import {
DatabaseError,
getS3ImageFilePath,
S3Client,
S3,
S3_BUCKET_PATH,
UploadError,
} from '@tupaia/utils';

export async function upsertAnswers(models, answers, surveyResponseId) {
const answerRecords = [];
Expand All @@ -16,8 +22,7 @@ export async function upsertAnswers(models, answers, surveyResponseId) {
question_id: answer.question_id,
survey_response_id: surveyResponseId,
};

if (answer.type === 'Photo') {
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
Expand All @@ -31,6 +36,21 @@ export async function upsertAnswers(models, answers, surveyResponseId) {
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')
) {
try {
const s3Client = new S3Client(new S3());
answerDocument.text = await s3Client.uploadFile(
answer.body.uniqueFileName,
answer.body.data,
);
} catch (error) {
throw new UploadError(error);
}
} 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 '../routes/SubmitSurvey/processSurveyResponse';

Expand All @@ -25,6 +25,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 @@ -385,4 +390,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',
},
},
},
mockFindEntityById,
);

expect(result).toEqual({
...processedResponseData,
answers: [
{
question_id: 'question1',
type: QuestionType.File,
body: {
data: 'theEncodedFile',
uniqueFileName: getUniqueSurveyQuestionFileName('theFileName'),
},
},
],
});
});
});
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,
Entity,
Expand All @@ -15,6 +15,7 @@ import { buildUpsertEntity } from './buildUpsertEntity';
type SurveyRequestT = DatatrakWebSubmitSurveyRequest.ReqBody;
type AnswerT = DatatrakWebSubmitSurveyRequest.Answer;
type AutocompleteAnswerT = DatatrakWebSubmitSurveyRequest.AutocompleteAnswer;
type FileUploadAnswerT = DatatrakWebSubmitSurveyRequest.FileUploadAnswer;

export const isUpsertEntityQuestion = (config?: SurveyScreenComponentConfig) => {
if (!config?.entity) {
Expand Down Expand Up @@ -92,7 +93,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 @@ -108,6 +108,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),
},
});
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 @@ -47,7 +47,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
39 changes: 35 additions & 4 deletions packages/types/src/schemas/schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58862,7 +58862,15 @@ export const AnswerTypeSchema = {
"type": "string"
},
"body": {
"type": "string"
"anyOf": [
{
"type": "object",
"additionalProperties": false
},
{
"type": "string"
}
]
},
"question_id": {
"type": "string"
Expand All @@ -58871,7 +58879,6 @@ export const AnswerTypeSchema = {
"additionalProperties": false,
"required": [
"body",
"id",
"question_id",
"type"
]
Expand Down Expand Up @@ -58902,7 +58909,15 @@ export const MeditrakSurveyResponseRequestSchema = {
"type": "string"
},
"body": {
"type": "string"
"anyOf": [
{
"type": "object",
"additionalProperties": false
},
{
"type": "string"
}
]
},
"question_id": {
"type": "string"
Expand All @@ -58911,7 +58926,6 @@ export const MeditrakSurveyResponseRequestSchema = {
"additionalProperties": false,
"required": [
"body",
"id",
"question_id",
"type"
]
Expand Down Expand Up @@ -59514,6 +59528,23 @@ export const AutocompleteAnswerSchema = {
]
}

export const FileUploadAnswerSchema = {
"type": "object",
"properties": {
"name": {
"type": "string"
},
"value": {
"type": "string"
}
},
"additionalProperties": false,
"required": [
"name",
"value"
]
}

export const AnswersSchema = {
"type": "object",
"additionalProperties": false
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,12 @@ import { Entity, Option } from '../../models';
type Id = string;

type AnswerType = {
id: Id;
id?: Id;
type: string;
body: string;
body: string | Record<string, unknown>;
/**
* @checkIdExists { "table": "question" }
*/
question_id: string;
};

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
Loading

0 comments on commit 7f12be1

Please sign in to comment.