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 all 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
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