Skip to content

Commit

Permalink
RN-1162: Fix visibility criteria check not updating when a answer cha…
Browse files Browse the repository at this point in the history
…nges (#5402)
  • Loading branch information
rohan-bes authored Feb 22, 2024
1 parent 7a7f619 commit 304b56a
Show file tree
Hide file tree
Showing 12 changed files with 293 additions and 344 deletions.
486 changes: 241 additions & 245 deletions packages/datatrak-web-server/src/__tests__/processSurveyResponse.test.ts

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,7 @@
*/
import { Request } from 'express';
import { Route } from '@tupaia/server-boilerplate';
import {
DatatrakWebSubmitSurveyRequest as RequestT,
DatatrakWebSubmitSurveyRequest,
Entity,
} from '@tupaia/types';
import { DatatrakWebSubmitSurveyRequest as RequestT } from '@tupaia/types';
import { processSurveyResponse } from './processSurveyResponse';
import { addRecentEntities } from '../../utils';

Expand All @@ -19,19 +15,14 @@ export type SubmitSurveyRequest = Request<
RequestT.ReqQuery
>;

type AnswerT = DatatrakWebSubmitSurveyRequest.Answer;

export class SubmitSurveyRoute extends Route<SubmitSurveyRequest> {
public async buildResponse() {
const surveyResponseData = this.req.body;
const { central: centralApi } = this.req.ctx.services;
const { session, models } = this.req;

// The processSurvey util needs this to look up entity records. Pass in a util function rather than the whole model context
const findEntityById = (entityId: string) => this.req.models.entity.findById(entityId);
const { session, models, ctx } = this.req;

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

await centralApi.createSurveyResponses(
[processedResponse],
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,15 +5,16 @@

import { generateId } from '@tupaia/database';
import { DatatrakWebSubmitSurveyRequest, Entity, SurveyScreenComponentConfig } from '@tupaia/types';
import { DatatrakWebServerModelRegistry } from '../../types';

type Answers = DatatrakWebSubmitSurveyRequest.ReqBody['answers'];

export const buildUpsertEntity = async (
models: DatatrakWebServerModelRegistry,
config: SurveyScreenComponentConfig,
questionId: string,
answers: Answers,
countryId: Entity['id'],
findEntityById: (id: string) => Promise<Entity>,
) => {
const entityId = (answers[questionId] || generateId()) as Entity['id'];
const entity = { id: entityId } as Entity;
Expand All @@ -30,20 +31,20 @@ export const buildUpsertEntity = async (
if (fieldName === 'parentId') {
// If the parentId field is not answered, use the country id
const parentValue = (fieldValue as string) || countryId;
const entityRecord = await findEntityById(parentValue);
const entityRecord = await models.entity.findById(parentValue);
entity.parent_id = entityRecord.id;
} else {
entity[fieldName as keyof Entity] = fieldValue;
}
}

const isUpdate = await findEntityById(entityId);
const isUpdate = await models.entity.findById(entityId);

if (isUpdate) {
return entity;
}

const selectedCountry = await findEntityById(countryId);
const selectedCountry = await models.entity.findById(countryId);
if (!entity.country_code) {
entity.country_code = selectedCountry.code;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,23 +2,25 @@
* 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';
import { buildUpsertEntity } from './buildUpsertEntity';
import { DatatrakWebServerModelRegistry } from '../../types';

type SurveyRequestT = DatatrakWebSubmitSurveyRequest.ReqBody;
type CentralServerSurveyResponseT = MeditrakSurveyResponseRequest & {
qr_codes_to_create?: Entity[];
recent_entities: string[];
};
type AnswerT = DatatrakWebSubmitSurveyRequest.Answer;
type AutocompleteAnswerT = DatatrakWebSubmitSurveyRequest.AutocompleteAnswer;
type FileUploadAnswerT = DatatrakWebSubmitSurveyRequest.FileUploadAnswer;

export const isUpsertEntityQuestion = (config?: SurveyScreenComponentConfig) => {
Expand All @@ -33,8 +35,9 @@ 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,
findEntityById: (id: string) => Promise<Entity>,
) => {
const {
surveyId,
Expand Down Expand Up @@ -68,19 +71,19 @@ export const processSurveyResponse = async (
const answersToSubmit = [] as Record<string, unknown>[];

for (const question of questions) {
const { questionId, type } = question;
const { questionId, code: questionCode, type, optionSetId } = question;
let answer = answers[questionId] as AnswerT | Entity;
const config = question?.config as SurveyScreenComponentConfig;

if ([QuestionType.PrimaryEntity, QuestionType.Entity].includes(type)) {
// If an entity should be created by this question, build the entity object. We need to do this before we get to the check for the answer being empty, because most of the time these questions are hidden and therefore the answer will always be empty
if (isUpsertEntityQuestion(config)) {
const entityObj = (await buildUpsertEntity(
models,
config,
questionId,
answers,
countryId,
findEntityById,
)) as Entity;
if (entityObj) surveyResponse.entities_upserted?.push(entityObj);
answer = entityObj?.id;
Expand Down Expand Up @@ -129,18 +132,33 @@ export const processSurveyResponse = async (
break;
}
case QuestionType.Autocomplete: {
if (!optionSetId) {
throw new Error(`Autocomplete question ${questionCode} does not have an optionSetId`);
}

if (typeof answer !== 'string') {
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);
// 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;
if (isNew) {
if (!config.autocomplete?.createNew) {
throw new Error(`Cannot create new options for question: ${questionCode}`);
}

surveyResponse.options_created!.push({
option_set_id: optionSetId,
value,
label,
value: answer,
label: answer,
});
}
answersToSubmit.push({
...answerObject,
body: value,
body: answer,
});
break;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -150,12 +150,7 @@ describe('Autocomplete Question', () => {
const displayOption = await screen.findByRole('option', { name: 'Add "Purple"' });
userEvent.click(displayOption);

expect(onChange).toHaveBeenCalledWith({
label: 'Purple',
value: 'Purple',
isNew: true,
optionSetId: props.optionSetId,
});
expect(onChange).toHaveBeenCalledWith('Purple');
});

it('Calls the onChange method with the option when an existing option is selected', async () => {
Expand All @@ -167,6 +162,6 @@ describe('Autocomplete Question', () => {
const displayOption = await screen.findByRole('option', { name: options[0].label });
userEvent.click(displayOption);

expect(onChange).toHaveBeenCalledWith(options[0]);
expect(onChange).toHaveBeenCalledWith(options[0].value);
});
});
9 changes: 1 addition & 8 deletions packages/datatrak-web/src/api/mutations/useSubmitSurvey.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,14 +13,7 @@ import { getAllSurveyComponents, useSurveyForm } from '../../features';
import { useSurvey } from '../queries';
import { gaEvent, successToast } from '../../utils';

type AutocompleteAnswer = {
isNew?: boolean;
optionSetId: string;
value: string;
label: string;
};

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

export type AnswersT = Record<string, Answer>;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -173,14 +173,9 @@ export const AutocompleteQuestion = ({
const { value } = option;
// if the option is not in the list of options, it is a new option
if (!data?.find(o => o.value === value)) {
onChange({
value,
label: value,
isNew: true,
optionSetId,
});
onChange(value);
} else {
onChange(option);
onChange(option.value);
}
};

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,7 @@ export const RadioQuestion = ({
<StyledRadioGroup
aria-describedby={`question_number_${id}`}
name={name!}
onChange={onChange}
onChange={e => onChange(e, e.target.value)}
id={id}
aria-invalid={invalid}
value={value || ''}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ export const TextQuestion = ({
label={label}
name={name!}
ref={ref}
onChange={onChange}
onChange={e => onChange(e, (e.target as HTMLInputElement).value)}
value={value}
required={required}
invalid={invalid}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -85,15 +85,6 @@ export const SurveyQuestion = ({
return <FieldComponent {...props} name={name} type={type} />;
}

// If the question dictates the visibility of any other questions, we need to update the formData when the value changes, so the visibility of other questions can be updated in real time. This doesn't happen that often, so it shouldn't have too much of a performance impact, and we are only updating the formData for the question that is changing, not the entire formData object.
const handleOnChange = e => {
if (updateFormDataOnChange) {
updateFormData({
[name]: e?.target ? e.target.value : e?.value,
});
}
};

const { mandatory: required, min, max } = validationCriteria || {};

const getDefaultValue = () => {
Expand Down Expand Up @@ -122,9 +113,16 @@ export const SurveyQuestion = ({
...renderProps,
invalid,
ref,
onChange: e => {
handleOnChange(e);
onChange(e);
onChange: (newValue: unknown, rawValue: unknown = newValue) => {
// If the question dictates the visibility of any other questions, we need to update the formData when the value changes,
// so the visibility of other questions can be updated in real time. This doesn't happen that often, so it shouldn't have too much of a performance impact,
// and we are only updating the formData for the question that is changing, not the entire formData object.
if (updateFormDataOnChange) {
updateFormData({
[name]: rawValue,
});
}
onChange(newValue);
},
}}
required={required}
Expand Down
24 changes: 0 additions & 24 deletions packages/types/src/schemas/schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62178,30 +62178,6 @@ export const CamelCasedSurveyScreenSchema = {
]
}

export const AutocompleteAnswerSchema = {
"type": "object",
"properties": {
"isNew": {
"type": "boolean"
},
"optionSetId": {
"type": "string"
},
"value": {
"type": "string"
},
"label": {
"type": "string"
}
},
"additionalProperties": false,
"required": [
"label",
"optionSetId",
"value"
]
}

export const FileUploadAnswerSchema = {
"type": "object",
"properties": {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,26 +5,12 @@
import { UserAccount, Survey, Entity } from '../../models';
import { SurveyScreenComponent } from './SurveyRequest';

export type AutocompleteAnswer = {
isNew?: boolean;
optionSetId: string;
value: string;
label: string;
};

export type FileUploadAnswer = {
name: string;
value: string;
};

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

export type Answers = Record<string, Answer>;

Expand Down

0 comments on commit 304b56a

Please sign in to comment.