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

(feat) O3-3712: add a concept answer to a concept in the form builder #351

Open
wants to merge 14 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 7 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
205 changes: 199 additions & 6 deletions src/components/interactive-builder/add-question.modal.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import React, { useState } from 'react';
import React, { useCallback, useEffect, useState } from 'react';
import { useTranslation, type TFunction } from 'react-i18next';
import flattenDeep from 'lodash-es/flattenDeep';
import {
Expand Down Expand Up @@ -62,7 +62,6 @@ interface AddQuestionModalProps {
interface Item {
text: string;
}

interface ProgramStateData {
selectedItems: Array<ProgramState>;
}
Expand Down Expand Up @@ -100,12 +99,15 @@ const AddQuestionModal: React.FC<AddQuestionModalProps> = ({
const [answers, setAnswers] = useState<Array<Answer>>([]);
const [conceptMappings, setConceptMappings] = useState<Array<ConceptMapping>>([]);
const [conceptToLookup, setConceptToLookup] = useState('');
const [conceptAnsToLookup, setConceptAnsToLookup] = useState('');
const debouncedAnsConceptToLookup = useDebounce(conceptAnsToLookup);
const debouncedConceptToLookup = useDebounce(conceptToLookup);
const [datePickerType, setDatePickerType] = useState<DatePickerType>('both');
const [renderingType, setRenderingType] = useState<RenderType | null>(null);
const [isQuestionRequired, setIsQuestionRequired] = useState(false);
const [max, setMax] = useState('');
const [min, setMin] = useState('');
const [addAnswer, setAnswer] = useState(false);
const [questionId, setQuestionId] = useState('');
const [questionLabel, setQuestionLabel] = useState('');
const [questionType, setQuestionType] = useState<QuestionType | null>(null);
Expand All @@ -116,16 +118,29 @@ const AddQuestionModal: React.FC<AddQuestionModalProps> = ({
text: string;
}>
>([]);
const [addedAnswers, setaddedAnswers] = useState<
Willie-theBeastMutua marked this conversation as resolved.
Show resolved Hide resolved
Array<{
id: string;
text: string;
}>
>([]);
const [selectedConcept, setSelectedConcept] = useState<Concept | null>(null);
const [selectedAnsConcept, setSelectedAnsConcept] = useState<Concept | null>(null);
const [selectedPersonAttributeType, setSelectedPersonAttributeType] = useState<PersonAttributeType | null>(null);
const { concepts, conceptLookupError, isLoadingConcepts } = useConceptLookup(debouncedConceptToLookup);
const {
concepts: ansConcepts,
conceptLookupError: conceptAnsLookupError,
isLoadingConcepts: isLoadingAnsConcepts,
} = useConceptLookup(debouncedAnsConceptToLookup);
const { personAttributeTypes, personAttributeTypeLookupError } = usePersonAttributeTypes();
const [selectedPatientIdetifierType, setSelectedPatientIdetifierType] = useState<PatientIdentifierType>(null);
const { patientIdentifierTypes, patientIdentifierTypeLookupError } = usePatientIdentifierTypes();
const [addObsComment, setAddObsComment] = useState(false);
const [addInlineDate, setAddInlineDate] = useState(false);
const [selectedProgramState, setSelectedProgramState] = useState<Array<ProgramState>>([]);
const [selectedProgram, setSelectedProgram] = useState<Program>(null);
const [isCreatingQuestion, setIsCreatingQuestion] = useState(false);
const [programWorkflow, setProgramWorkflow] = useState<ProgramWorkflow>(null);
const { programs, programsLookupError, isLoadingPrograms } = usePrograms();
const { programStates, programStatesLookupError, isLoadingProgramStates, mutateProgramStates } = useProgramWorkStates(
Expand Down Expand Up @@ -156,10 +171,13 @@ const AddQuestionModal: React.FC<AddQuestionModalProps> = ({
};

const handleConceptChange = (event: React.ChangeEvent<HTMLInputElement>) => setConceptToLookup(event.target.value);
const handleAnsConceptChange = (event: React.ChangeEvent<HTMLInputElement>) =>
setConceptAnsToLookup(event.target.value);

const handleConceptSelect = (concept: Concept) => {
const updatedDatePickerType = getDatePickerType(concept);
if (updatedDatePickerType) setDatePickerType(updatedDatePickerType);
setAnswer(false);
setConceptToLookup('');
setSelectedConcept(concept);
setAnswers(
Expand All @@ -179,6 +197,30 @@ const AddQuestionModal: React.FC<AddQuestionModalProps> = ({
}),
);
};
const handleDeleteAnswer = (id) => {
setaddedAnswers((prevAnswers) => prevAnswers.filter((answer) => answer.id !== id));
};
const handleSaveMoreAnswers = () => {
const newAnswers = addedAnswers.filter(
(newAnswer) => !selectedAnswers.some((prevAnswer) => prevAnswer.id === newAnswer.id),
);

const updatedAnswers = [...selectedAnswers, ...newAnswers];
setSelectedAnswers(updatedAnswers);
setaddedAnswers([]);
setIsCreatingQuestion(true);
};

const handleConceptAnsSelect = (concept: Concept) => {
setConceptAnsToLookup('');
setSelectedAnsConcept(concept);
const newAnswer = { id: concept.uuid, text: concept.display };
const answerExistsInSelected = selectedAnswers.some((answer) => answer.id === newAnswer.id);
const answerExistsInAdded = addedAnswers.some((answer) => answer.id === newAnswer.id);
if (!answerExistsInSelected && !answerExistsInAdded) {
setaddedAnswers((prevAnswers) => [...prevAnswers, newAnswer]);
}
};

const handlePersonAttributeTypeChange = ({ selectedItem }: { selectedItem: PersonAttributeType }) => {
setSelectedPersonAttributeType(selectedItem);
Expand All @@ -201,11 +243,11 @@ const AddQuestionModal: React.FC<AddQuestionModalProps> = ({
};

const handleCreateQuestion = () => {
createQuestion();
handleSaveMoreAnswers();
closeModal();
};

const createQuestion = () => {
const createQuestion = useCallback(() => {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just for clarification purposes, any particular reason as to why we changed this to a useCallback?

try {
const questionIndex = schema.pages[pageIndex]?.sections?.[sectionIndex]?.questions?.length ?? 0;
const computedQuestionId = `question${questionIndex + 1}Section${sectionIndex + 1}Page-${pageIndex + 1}`;
Expand Down Expand Up @@ -289,7 +331,33 @@ const AddQuestionModal: React.FC<AddQuestionModalProps> = ({
});
}
}
};
}, [
onSchemaChange,
t,
selectedProgram?.uuid,
schema,
pageIndex,
sectionIndex,
questionLabel,
questionType,
isQuestionRequired,
questionId,
renderingType,
datePickerType,
selectedConcept,
conceptMappings,
selectedAnswers,
addObsComment,
addInlineDate,
selectedPersonAttributeType,
selectedPatientIdetifierType,
selectedProgramState,
programWorkflow,
toggleLabelTrue,
toggleLabelFalse,
max,
min,
]);

const convertLabelToCamelCase = () => {
const camelCasedLabel = questionLabel
Expand All @@ -301,6 +369,14 @@ const AddQuestionModal: React.FC<AddQuestionModalProps> = ({
setQuestionId(camelCasedLabel);
};

const showAddQuestion = () => {
if (!addAnswer) {
setAnswer(true);
return;
}
setAnswer(false);
};

const handleProgramWorkflowChange = (selectedItem: ProgramWorkflow) => {
setProgramWorkflow(selectedItem);
void mutateProgramStates();
Expand All @@ -310,6 +386,12 @@ const AddQuestionModal: React.FC<AddQuestionModalProps> = ({
setSelectedProgram(selectedItem);
setProgramWorkflows(selectedItem?.allWorkflows);
};
useEffect(() => {
if (isCreatingQuestion) {
createQuestion();
setIsCreatingQuestion(false);
}
}, [isCreatingQuestion, createQuestion]);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can't we just call the handleCreateQuestion function, instead of having a useEffect to do it? See - https://react.dev/learn/you-might-not-need-an-effect

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The use effect is used due to react's state management when combining the added answers and the selected answers. Saving without checking when the state is changed only saves the selected answers and not the update. I tried not using it but react's state management did not allow me. The call back is used to execute also the use effect. They are both part of the same solution

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ok I see. So instead of having a useEffect and useCallback we could change the handleSaveMoreAnswers to return the answers list, like so:

const handleSaveMoreAnswers = () => {
    const newAnswers = addedAnswers.filter(
      (newAnswer) => !selectedAnswers.some((prevAnswer) => prevAnswer.id === newAnswer.id),
    );

    const updatedAnswers = [...selectedAnswers, ...newAnswers];
    setSelectedAnswers(updatedAnswers);
    setaddedAnswers([]);
    return updatedAnswers;
  };

And then in the handleCreateQuestion, we pass in the list to the createQuestion function, like so:

 const handleCreateQuestion = () => {
    const updatedAnswers = handleSaveMoreAnswers();
    createQuestion(updatedAnswers);
    closeModal();
  };

And accept change the createQuestion function to accept a parameter and then use that param for the object:

const createQuestion = (conceptAnswers) => {
    try {
      const questionIndex = schema.pages[pageIndex]?.sections?.[sectionIndex]?.questions?.length ?? 0;
      const computedQuestionId = `question${questionIndex + 1}Section${sectionIndex + 1}Page-${pageIndex + 1}`;

      const newQuestion = {
        label: questionLabel,
        ......,
        ...(conceptAnswers.length && {
            answers: conceptAnswers.map((answer) => ({
              concept: answer.id,
              label: answer.text,
            })),
          }),
      };
    }
};

This way we could avoid the useCallback with a massive dependency list

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

let me try that


return (
<>
Expand Down Expand Up @@ -688,7 +770,6 @@ const AddQuestionModal: React.FC<AddQuestionModalProps> = ({
titleText={t('selectAnswersToDisplay', 'Select answers to display')}
/>
) : null}

{selectedAnswers.length ? (
<div>
{selectedAnswers.map((answer) => (
Expand All @@ -698,6 +779,118 @@ const AddQuestionModal: React.FC<AddQuestionModalProps> = ({
))}
</div>
) : null}
{selectedConcept && answers?.length ? (
<div>
<Button kind="tertiary" onClick={showAddQuestion} iconDescription="Add" size="sm">
More Answers
</Button>
</div>
) : null}
{addAnswer ? (
<div>
<FormLabel className={styles.label}>
{t('searchForAnswerConcept', 'Search for an answer Concept to Add')}
</FormLabel>
{conceptAnsLookupError ? (
<InlineNotification
kind="error"
lowContrast
className={styles.error}
title={t('errorFetchingConcepts', 'Error fetching concepts')}
subtitle={t('pleaseTryAgain', 'Please try again.')}
/>
) : null}
<Search
id="conceptAnsLookup"
onClear={() => {
setSelectedAnsConcept(null);
}}
onChange={handleAnsConceptChange}
placeholder={t('searchConcept', 'Search using a concept name or UUID')}
required
size="md"
value={(() => {
if (conceptAnsToLookup) {
return conceptAnsToLookup;
}
if (selectedAnsConcept) {
return selectedAnsConcept.display;
}
return '';
})()}
/>
{addedAnswers.length > 0 ? (
<div>
{addedAnswers.map((answer) => (
<Tag className={styles.tag} key={answer.id} type={'blue'}>
{answer.text}
<button
className={styles.conceptAnswerButton}
onClick={() => handleDeleteAnswer(answer.id)}
>
X
</button>
</Tag>
))}
</div>
) : null}

{(() => {
if (!conceptAnsToLookup) return null;
if (isLoadingAnsConcepts)
return (
<InlineLoading
className={styles.loader}
description={t('searching', 'Searching') + '...'}
/>
);
if (ansConcepts?.length && !isLoadingAnsConcepts) {
return (
<ul className={styles.conceptList}>
{ansConcepts?.map((concept, index) => (
<li
role="menuitem"
className={styles.concept}
key={index}
onClick={() => handleConceptAnsSelect(concept)}
>
{concept.display}
</li>
))}
</ul>
);
}

return (
<Layer>
<Tile className={styles.emptyResults}>
<span>
{t('noMatchingConcepts', 'No concepts were found that match')}{' '}
<strong>"{debouncedAnsConceptToLookup}".</strong>
</span>
</Tile>

<div className={styles.oclLauncherBanner}>
{
<p className={styles.bodyShort01}>
{t('conceptSearchHelpText', "Can't find a concept?")}
</p>
}
<a
className={styles.oclLink}
target="_blank"
rel="noopener noreferrer"
href={'https://app.openconceptlab.org/'}
>
{t('searchInOCL', 'Search in OCL')}
<ArrowUpRight size={16} />
</a>
</div>
</Layer>
);
})()}
</div>
) : null}

<Stack gap={5}>
<RadioButtonGroup
Expand Down
Loading