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

RN-1165: Add validation handler for file questions #5416

Merged
merged 6 commits into from
Feb 25, 2024
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
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]
);
};
Loading