Skip to content

Commit

Permalink
RN-1165: Add validation handler for file questions (#5416)
Browse files Browse the repository at this point in the history
* RN-1165: Add validation handler for file questions

* Autocomplete fix

* Remove unused var

---------

Co-authored-by: Andrew <vanbeekandrew@gmail.com>
  • Loading branch information
alexd-bes and avaek authored Feb 25, 2024
1 parent 7dcb83a commit 522d57e
Show file tree
Hide file tree
Showing 6 changed files with 80 additions and 56 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@
* Tupaia
* Copyright (c) 2017 - 2023 Beyond Essential Systems Pty Ltd
*/
import { MockCentralApi, MockTupaiaApiClient } from '@tupaia/api-client';
import { EntityType, QuestionType } from '@tupaia/types';
import { getUniqueSurveyQuestionFileName } from '@tupaia/utils';
import { generateId } from '@tupaia/database';
Expand All @@ -22,17 +21,11 @@ const mockModels = {
entity: {
findById: mockFindEntityById,
},
option: {
findOne: async ({ value }: { value: any }) => (value === '2' ? { value } : null),
},
} as DatatrakWebServerModelRegistry;

const mockApiClient = new MockTupaiaApiClient({
central: new MockCentralApi({
[`optionSets/${optionSetId}/options`]: [
{ label: 'One', value: '1' },
{ label: 'Two', value: '2' },
],
}),
});

jest.mock('@tupaia/database', () => ({
generateId: jest.fn(() => 'theEntityId'),
}));
Expand Down Expand Up @@ -76,7 +69,7 @@ describe('processSurveyResponse', () => {
};

it('should process the survey response with standard question types', async () => {
const result = await processSurveyResponse(mockModels, mockApiClient, {
const result = await processSurveyResponse(mockModels, {
...responseData,
questions: [
{
Expand Down Expand Up @@ -118,7 +111,7 @@ describe('processSurveyResponse', () => {
});

it('should set the entity_id as the answer when question type is "PrimaryEntity"', async () => {
const result = await processSurveyResponse(mockModels, mockApiClient, {
const result = await processSurveyResponse(mockModels, {
...responseData,
questions: [
{
Expand All @@ -143,7 +136,7 @@ describe('processSurveyResponse', () => {
});

it('should set the data_time as the answer when question type is "DateOfData"', async () => {
const result = await processSurveyResponse(mockModels, mockApiClient, {
const result = await processSurveyResponse(mockModels, {
...responseData,
questions: [
{
Expand All @@ -167,7 +160,7 @@ describe('processSurveyResponse', () => {
});

it('should set the data_time as the answer when question type is "SubmissionDate"', async () => {
const result = await processSurveyResponse(mockModels, mockApiClient, {
const result = await processSurveyResponse(mockModels, {
...responseData,
questions: [
{
Expand All @@ -191,7 +184,7 @@ describe('processSurveyResponse', () => {
});

it('should add new options to options_created when type is "Autocomplete" and answer not in the optionSet', async () => {
const result = await processSurveyResponse(mockModels, mockApiClient, {
const result = await processSurveyResponse(mockModels, {
...responseData,
questions: [
{
Expand Down Expand Up @@ -230,7 +223,7 @@ describe('processSurveyResponse', () => {
});

it('should not add new options to options_created when type is "Autocomplete" and answer is in the optionSet', async () => {
const result = await processSurveyResponse(mockModels, mockApiClient, {
const result = await processSurveyResponse(mockModels, {
...responseData,
questions: [
{
Expand Down Expand Up @@ -264,7 +257,7 @@ describe('processSurveyResponse', () => {

it('should throw an error when type is "Autocomplete" and answer is not in the optionSet but createNew is not true', async () => {
await expect(() =>
processSurveyResponse(mockModels, mockApiClient, {
processSurveyResponse(mockModels, {
...responseData,
questions: [
{
Expand All @@ -286,7 +279,7 @@ describe('processSurveyResponse', () => {
});

it('should not add to entities_upserted when type is "Entity" and a create config is not set', async () => {
const result = await processSurveyResponse(mockModels, mockApiClient, {
const result = await processSurveyResponse(mockModels, {
...responseData,
questions: [
{
Expand Down Expand Up @@ -316,7 +309,7 @@ describe('processSurveyResponse', () => {
});

it('should add to entities_upserted when type is "Entity" and a create config is set', async () => {
const result = await processSurveyResponse(mockModels, mockApiClient, {
const result = await processSurveyResponse(mockModels, {
...responseData,
questions: [
{
Expand Down Expand Up @@ -363,7 +356,7 @@ describe('processSurveyResponse', () => {
});

it('should add to qr_codes_to_create when type is "Entity" and a generateQRCode config is set', async () => {
const result = await processSurveyResponse(mockModels, mockApiClient, {
const result = await processSurveyResponse(mockModels, {
...responseData,
questions: [
{
Expand Down Expand Up @@ -417,7 +410,7 @@ describe('processSurveyResponse', () => {
});

it('should add to entities_upserted when type is "PrimaryEntity" and a create config is set', async () => {
const result = await processSurveyResponse(mockModels, mockApiClient, {
const result = await processSurveyResponse(mockModels, {
...responseData,
questions: [
{
Expand Down Expand Up @@ -458,7 +451,7 @@ describe('processSurveyResponse', () => {
});

it('should use the country id for new entities if parent id is not filled in', async () => {
const result = await processSurveyResponse(mockModels, mockApiClient, {
const result = await processSurveyResponse(mockModels, {
...responseData,
questions: [
{
Expand Down Expand Up @@ -498,7 +491,7 @@ describe('processSurveyResponse', () => {
});
});
it('should handle when question type is File', async () => {
const result = await processSurveyResponse(mockModels, mockApiClient, {
const result = await processSurveyResponse(mockModels, {
...responseData,
questions: [
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,10 +19,10 @@ export class SubmitSurveyRoute extends Route<SubmitSurveyRequest> {
public async buildResponse() {
const surveyResponseData = this.req.body;
const { central: centralApi } = this.req.ctx.services;
const { session, models, ctx } = this.req;
const { session, models } = this.req;

const { qr_codes_to_create, recent_entities, ...processedResponse } =
await processSurveyResponse(models, ctx.services, surveyResponseData);
await processSurveyResponse(models, surveyResponseData);

await centralApi.createSurveyResponses(
[processedResponse],
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,11 @@
* Tupaia
* Copyright (c) 2017 - 2023 Beyond Essential Systems Pty Ltd
*/
import { TupaiaApiClient } from '@tupaia/api-client';
import { getUniqueSurveyQuestionFileName } from '@tupaia/utils';
import {
DatatrakWebSubmitSurveyRequest,
Entity,
MeditrakSurveyResponseRequest,
Option,
QuestionType,
SurveyScreenComponentConfig,
} from '@tupaia/types';
Expand Down Expand Up @@ -36,7 +34,6 @@ export const isUpsertEntityQuestion = (config?: SurveyScreenComponentConfig) =>
// Process the survey response data into the format expected by the endpoint
export const processSurveyResponse = async (
models: DatatrakWebServerModelRegistry,
apiClient: TupaiaApiClient,
surveyResponseData: SurveyRequestT,
) => {
const {
Expand Down Expand Up @@ -140,10 +137,10 @@ export const processSurveyResponse = async (
throw new Error(`Autocomplete answers must be a plain string value, got: ${answer}`);
}

const options = (await apiClient.central.fetchResources(
`optionSets/${optionSetId}/options`,
)) as Option[];
const isNew = !options.map(({ value }) => value).includes(answer);
const isNew = (await models.option.findOne({ option_set_id: optionSetId, value: answer }))
? false
: true;

// if the answer is a new option, add it to the options_created array to be added to the DB
if (isNew) {
if (!config.autocomplete?.createNew) {
Expand Down
2 changes: 2 additions & 0 deletions packages/datatrak-web-server/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
import { Model } from '@tupaia/server-boilerplate';
import { Entity, OneTimeLogin } from '@tupaia/types';
import { FeedItemModel, SurveyResponseModel, UserModel } from './models';
import { OptionModel } from '@tupaia/database';

export type EntityType = BaseEntityType & Entity;
export type OneTimeLoginType = BaseOneTimeLoginType & OneTimeLogin;
Expand All @@ -25,4 +26,5 @@ export interface DatatrakWebServerModelRegistry extends ModelRegistry {
readonly user: UserModel;
readonly survey: SurveyModel;
readonly oneTimeLogin: Model<OneTimeLoginModel, OneTimeLogin, OneTimeLoginType>;
readonly option: OptionModel;
}
12 changes: 12 additions & 0 deletions packages/datatrak-web/src/api/mutations/useSubmitSurvey.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,18 @@ export const useSubmitSurvey = () => {
queryClient.invalidateQueries('rewards');
queryClient.invalidateQueries('leaderboard');
queryClient.invalidateQueries('entityDescendants'); // Refresh recent entities

const createNewAutocompleteQuestions = surveyResponseData?.questions?.filter(
question => question?.config?.autocomplete?.createNew,
);

// invalidate optionSet queries for questions that have createNew enabled so that the new options are fetched
if (createNewAutocompleteQuestions?.length > 0) {
createNewAutocompleteQuestions.forEach(question => {
const { optionSetId } = question;
queryClient.invalidateQueries(['autocompleteOptions', optionSetId]);
});
}
resetForm();
successToast("Congratulations! You've earned a coconut", Coconut);
// include the survey response data in the location state, so that we can use it to generate QR codes
Expand Down
68 changes: 44 additions & 24 deletions packages/datatrak-web/src/features/Survey/useValidationResolver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,20 +2,34 @@
* Tupaia
* Copyright (c) 2017 - 2023 Beyond Essential Systems Pty Ltd
*/
import { useCallback } from 'react';
import * as yup from 'yup';
import { QuestionType } from '@tupaia/types';
import { getAllSurveyComponents, useSurveyForm } from '.';
import { SurveyScreenComponent } from '../../types';
import { useCallback } from "react";
import * as yup from "yup";
import { QuestionType } from "@tupaia/types";
import { SurveyScreenComponent } from "../../types";
import { getAllSurveyComponents, useSurveyForm } from ".";

const transformNumberValue = (value: string | number, originalValue: string | number) => {
const transformNumberValue = (
value: string | number,
originalValue: string | number
) => {
// This is a workaround for yup not handling empty number fields (https://github.com/jquense/yup/issues/298)
return originalValue === '' || isNaN(originalValue as number) ? null : value;
return originalValue === "" || isNaN(originalValue as number) ? null : value;
};

const getBaseSchema = (type: QuestionType) => {
switch (type) {
case QuestionType.Number:
return yup.number().transform(transformNumberValue).nullable();
case QuestionType.File:
return yup
.object()
.shape({
value: yup.string(),
name: yup.string(),
})
.nullable()
.default(null); // Allow this value to be empty to stop a typeError. The mandatory validation will handle this instead

case QuestionType.Date:
case QuestionType.SubmissionDate:
case QuestionType.DateOfData:
Expand All @@ -29,13 +43,13 @@ const getBaseSchema = (type: QuestionType) => {
.object({
latitude: yup
.number()
.max(90, 'Latitude must be between -90 and 90')
.min(-90, 'Latitude must be between -90 and 90')
.max(90, "Latitude must be between -90 and 90")
.min(-90, "Latitude must be between -90 and 90")
.transform(transformNumberValue),
longitude: yup
.number()
.max(180, 'Longitude must be between -180 and 180')
.min(-180, 'Longitude must be between -180 and 180')
.max(180, "Longitude must be between -180 and 180")
.min(-180, "Longitude must be between -180 and 180")
.transform(transformNumberValue),
})
.nullable()
Expand All @@ -55,36 +69,42 @@ const getValidationSchema = (screenComponents?: SurveyScreenComponent[]) => {
let fieldSchema = getBaseSchema(type);

if (mandatory) {
fieldSchema = fieldSchema.required('Required');
fieldSchema = fieldSchema.required("Required");
// add custom validation for geolocate only when the question is required, so that the validation doesn't happen on each subfield when the question is not required
if (type === QuestionType.Geolocate) {
// @ts-ignore - handle issue with union type on schema from yup
fieldSchema = fieldSchema.test(
'hasLatLong',
"hasLatLong",
({ value }) => {
// Show the required message when the user has not entered a location at all
if (
(!value?.latitude && !value?.longitude) ||
(isNaN(value.latitude) && isNaN(value.longitude))
)
return 'Required';
return "Required";
// Otherwise show the invalid location message
return 'Please enter a valid location';
return "Please enter a valid location";
},
value =>
(value) =>
value?.latitude &&
value?.longitude &&
!isNaN(value.latitude) &&
!isNaN(value.longitude),
!isNaN(value.longitude)
);
}
}

if (min !== undefined) {
fieldSchema = (fieldSchema as yup.NumberSchema).min(min, `Minimum value is ${min}`);
fieldSchema = (fieldSchema as yup.NumberSchema).min(
min,
`Minimum value is ${min}`
);
}
if (max !== undefined) {
fieldSchema = (fieldSchema as yup.NumberSchema).max(max, `Maximum value is ${max}`);
fieldSchema = (fieldSchema as yup.NumberSchema).max(
max,
`Maximum value is ${max}`
);
}

return {
Expand All @@ -106,7 +126,7 @@ export const useValidationResolver = () => {
const yupSchema = yup.object().shape(validationSchema);

return useCallback(
async data => {
async (data) => {
try {
const values = await yupSchema.validate(
{
Expand All @@ -115,7 +135,7 @@ export const useValidationResolver = () => {
},
{
abortEarly: false,
},
}
);

return {
Expand All @@ -126,18 +146,18 @@ export const useValidationResolver = () => {
return {
values: {},
errors: errors?.inner?.reduce((allErrors, currentError) => {
const questionName = currentError.path?.split('.')[0];
const questionName = currentError.path?.split(".")[0];
return {
...allErrors,
[questionName]: {
type: currentError.type ?? 'validation',
type: currentError.type ?? "validation",
message: currentError.message,
},
};
}, {}),
};
}
},
[yupSchema],
[yupSchema]
);
};

0 comments on commit 522d57e

Please sign in to comment.