From 59a2ff946093002231188670974f3a6d52456823 Mon Sep 17 00:00:00 2001 From: Matt Bemis Date: Wed, 12 Jun 2024 11:30:08 -0400 Subject: [PATCH] [JN-1088] Admin form UX improvements + new task type (#922) --- .../pearl/core/model/survey/SurveyType.java | 3 +- .../pearl/core/model/workflow/TaskType.java | 3 +- .../service/survey/SurveyResponseService.java | 4 + .../service/survey/SurveyTaskDispatcher.java | 6 +- ui-admin/src/study/StudyContent.tsx | 81 ++++--- .../enrolleeView/EnrolleeView.tsx | 103 ++++++--- .../participantList/ParticipantList.tsx | 4 +- .../survey/EnrolleeSurveyView.test.tsx | 8 +- .../participants/survey/PrintFormModal.tsx | 3 +- .../survey/SurveyFullDataView.test.tsx | 18 +- .../survey/SurveyFullDataView.tsx | 14 +- .../survey/SurveyResponseEditor.tsx | 16 +- .../survey/SurveyResponseView.test.tsx | 57 +++++ .../survey/SurveyResponseView.tsx | 210 ++++++++++++++---- .../src/study/surveys/CreateSurveyModal.tsx | 7 +- .../src/study/surveys/FormOptions.test.tsx | 13 ++ .../src/study/surveys/FormOptionsModal.tsx | 54 +++-- ui-admin/src/test-utils/mocking-utils.tsx | 9 +- ui-admin/src/util/tableUtils.tsx | 16 +- .../components/forms/PagedSurveyView.test.tsx | 3 +- .../src/components/forms/PagedSurveyView.tsx | 15 +- ui-core/src/index.ts | 2 +- ui-core/src/types/forms.ts | 2 +- ui-core/src/types/task.ts | 1 + ui-participant/src/hub/survey/SurveyView.tsx | 2 + 25 files changed, 449 insertions(+), 205 deletions(-) create mode 100644 ui-admin/src/study/participants/survey/SurveyResponseView.test.tsx diff --git a/core/src/main/java/bio/terra/pearl/core/model/survey/SurveyType.java b/core/src/main/java/bio/terra/pearl/core/model/survey/SurveyType.java index 04bddfa7de..de5ebe0436 100644 --- a/core/src/main/java/bio/terra/pearl/core/model/survey/SurveyType.java +++ b/core/src/main/java/bio/terra/pearl/core/model/survey/SurveyType.java @@ -3,5 +3,6 @@ public enum SurveyType { CONSENT, // for consent forms RESEARCH, // for surveys intended to be included in the research dataset of a study - OUTREACH // for surveys intended for outreach purposes, e.g. to collect marketing/feedback information + OUTREACH, // for surveys intended for outreach purposes, e.g. to collect marketing/feedback information + ADMIN // for surveys intended for study staff purposes, e.g. for data entry } diff --git a/core/src/main/java/bio/terra/pearl/core/model/workflow/TaskType.java b/core/src/main/java/bio/terra/pearl/core/model/workflow/TaskType.java index 1626cf0a70..c40c679698 100644 --- a/core/src/main/java/bio/terra/pearl/core/model/workflow/TaskType.java +++ b/core/src/main/java/bio/terra/pearl/core/model/workflow/TaskType.java @@ -4,5 +4,6 @@ public enum TaskType { CONSENT, SURVEY, // a research survey OUTREACH, // an outreach activity -- not essential for research - KIT_REQUEST + KIT_REQUEST, + ADMIN_FORM // a task for study staff to complete -- not visible to participants } diff --git a/core/src/main/java/bio/terra/pearl/core/service/survey/SurveyResponseService.java b/core/src/main/java/bio/terra/pearl/core/service/survey/SurveyResponseService.java index 1657ee94b8..62a42c9147 100644 --- a/core/src/main/java/bio/terra/pearl/core/service/survey/SurveyResponseService.java +++ b/core/src/main/java/bio/terra/pearl/core/service/survey/SurveyResponseService.java @@ -134,6 +134,10 @@ public HubResponse updateResponse(SurveyResponse responseDto, Re SurveyResponse response = findOrCreateResponse(task, enrollee, enrollee.getParticipantUserId(), responseDto, portalId, operator); List updatedAnswers = createOrUpdateAnswers(responseDto.getAnswers(), response, survey, ppUser); + List allAnswers = new ArrayList<>(response.getAnswers()); + List existingAnswers = answerService.findByResponse(response.getId()); + allAnswers.addAll(existingAnswers); + response.setAnswers(allAnswers); DataAuditInfo auditInfo = DataAuditInfo.builder() .enrolleeId(enrollee.getId()) diff --git a/core/src/main/java/bio/terra/pearl/core/service/survey/SurveyTaskDispatcher.java b/core/src/main/java/bio/terra/pearl/core/service/survey/SurveyTaskDispatcher.java index 9952718b3d..c00bc9fc1b 100644 --- a/core/src/main/java/bio/terra/pearl/core/service/survey/SurveyTaskDispatcher.java +++ b/core/src/main/java/bio/terra/pearl/core/service/survey/SurveyTaskDispatcher.java @@ -22,7 +22,6 @@ import bio.terra.pearl.core.service.survey.event.SurveyPublishedEvent; import bio.terra.pearl.core.service.workflow.*; import lombok.extern.slf4j.Slf4j; -import okhttp3.internal.concurrent.Task; import org.springframework.context.event.EventListener; import org.springframework.core.annotation.Order; import org.springframework.stereotype.Service; @@ -185,7 +184,7 @@ public void handleSurveyPublished(SurveyPublishedEvent event) { } if (event.getSurvey().isAssignToExistingEnrollees()) { ParticipantTaskAssignDto assignDto = new ParticipantTaskAssignDto( - TaskType.SURVEY, + taskTypeForSurveyType.get(event.getSurvey().getSurveyType()), event.getSurvey().getStableId(), event.getSurvey().getVersion(), null, @@ -248,7 +247,8 @@ public ParticipantTask buildTask(Enrollee enrollee, PortalParticipantUser portal private final Map taskTypeForSurveyType = Map.of( SurveyType.CONSENT, TaskType.CONSENT, SurveyType.RESEARCH, TaskType.SURVEY, - SurveyType.OUTREACH, TaskType.OUTREACH + SurveyType.OUTREACH, TaskType.OUTREACH, + SurveyType.ADMIN, TaskType.ADMIN_FORM ); /** diff --git a/ui-admin/src/study/StudyContent.tsx b/ui-admin/src/study/StudyContent.tsx index 8d61512a62..d726bc54e6 100644 --- a/ui-admin/src/study/StudyContent.tsx +++ b/ui-admin/src/study/StudyContent.tsx @@ -53,18 +53,17 @@ function StudyContent({ studyEnvContext }: {studyEnvContext: StudyEnvContextT}) }, { setIsLoading }) } - const researchSurveyStableIds = _uniq(configuredSurveys - .filter(configSurvey => configSurvey.survey.surveyType === 'RESEARCH') - .sort((a, b) => a.surveyOrder - b.surveyOrder) - .map(configSurvey => configSurvey.survey.stableId)) - const outreachSurveyStableIds = _uniq(configuredSurveys - .filter(configSurvey => configSurvey.survey.surveyType === 'OUTREACH') - .sort((a, b) => a.surveyOrder - b.surveyOrder) - .map(configSurvey => configSurvey.survey.stableId)) - const consentSurveyStableIds = _uniq(configuredSurveys - .filter(configSurvey => configSurvey.survey.surveyType === 'CONSENT') - .sort((a, b) => a.surveyOrder - b.surveyOrder) - .map(configSurvey => configSurvey.survey.stableId)) + function getUniqueStableIdsForType(configuredSurveys: StudyEnvironmentSurveyNamed[], surveyType: string) { + return _uniq(configuredSurveys + .filter(configSurvey => configSurvey.survey.surveyType === surveyType) + .sort((a, b) => a.surveyOrder - b.surveyOrder) + .map(configSurvey => configSurvey.survey.stableId)) + } + + const researchSurveyStableIds = getUniqueStableIdsForType(configuredSurveys, 'RESEARCH') + const outreachSurveyStableIds = getUniqueStableIdsForType(configuredSurveys, 'OUTREACH') + const consentSurveyStableIds = getUniqueStableIdsForType(configuredSurveys, 'CONSENT') + const adminFormStableIds = getUniqueStableIdsForType(configuredSurveys, 'ADMIN') return
{ renderPageHeader('Forms & Surveys') } @@ -72,31 +71,31 @@ function StudyContent({ studyEnvContext }: {studyEnvContext: StudyEnvContextT})
{ currentEnv.studyEnvironmentConfig.initialized &&
  • -
    Pre-enrollment questionnaire
    +
    Pre-enrollment Questionnaire
    - { preEnrollSurvey &&
      + {preEnrollSurvey &&
      • {preEnrollSurvey.name} v{preEnrollSurvey.version} - { !isReadOnlyEnv &&
        - +
        } +
    }
} - { (!preEnrollSurvey && !isReadOnlyEnv) &&
  • -

    Consent forms

    +

    Consent Forms

  • +
  • +
    Study Staff Forms
    +
    + +
    + +
    +
    +
  • Outreach
    @@ -172,18 +193,18 @@ function StudyContent({ studyEnvContext }: {studyEnvContext: StudyEnvContextT})
  • - } - { createSurveyType && setCreateSurveyType(undefined)}/> } - { (showArchiveSurveyModal && selectedSurveyConfig) && } + {createSurveyType && setCreateSurveyType(undefined)}/>} + {(showArchiveSurveyModal && selectedSurveyConfig) && setShowArchiveSurveyModal(false)}/> } - { (showDeleteSurveyModal && selectedSurveyConfig) && setShowArchiveSurveyModal(false)}/>} + {(showDeleteSurveyModal && selectedSurveyConfig) && setShowDeleteSurveyModal(false)}/> } - { showCreatePreEnrollSurveyModal && setShowCreatePreEnrollModal(false)}/> } - { !currentEnv.studyEnvironmentConfig.initialized &&
    Not yet initialized
    } + onDismiss={() => setShowDeleteSurveyModal(false)}/>} + {showCreatePreEnrollSurveyModal && setShowCreatePreEnrollModal(false)}/>} + {!currentEnv.studyEnvironmentConfig.initialized &&
    Not yet initialized
    } diff --git a/ui-admin/src/study/participants/enrolleeView/EnrolleeView.tsx b/ui-admin/src/study/participants/enrolleeView/EnrolleeView.tsx index c01116484c..2c002501fa 100644 --- a/ui-admin/src/study/participants/enrolleeView/EnrolleeView.tsx +++ b/ui-admin/src/study/participants/enrolleeView/EnrolleeView.tsx @@ -1,4 +1,4 @@ -import React from 'react' +import React, { useEffect, useState } from 'react' import { ParticipantTask, StudyEnvironmentSurvey, SurveyResponse } from 'api/api' import { StudyEnvContextT } from 'study/StudyEnvironmentRouter' import { Link, NavLink, Route, Routes } from 'react-router-dom' @@ -35,34 +35,19 @@ export default function EnrolleeView({ studyEnvContext }: { studyEnvContext: Stu const { isLoading, enrollee, reload } = useRoutedEnrollee(studyEnvContext) return <> {isLoading && } - {!isLoading && enrollee && } + {!isLoading && enrollee && + } } /** shows a master-detail view for an enrollee with sub views on surveys, tasks, etc... */ -export function LoadedEnrolleeView({ enrollee, studyEnvContext, onUpdate }: - { enrollee: Enrollee, studyEnvContext: StudyEnvContextT, onUpdate: () => void }) { +export function LoadedEnrolleeView({ enrollee, studyEnvContext, onUpdate }: { + enrollee: Enrollee, studyEnvContext: StudyEnvContextT, onUpdate: () => void +}) { const { currentEnv, currentEnvPath } = studyEnvContext + const [responseMap, setResponseMap] = useState({}) const surveys: StudyEnvironmentSurvey[] = currentEnv.configuredSurveys - const responseMap: ResponseMapT = {} - surveys.forEach(configSurvey => { - // to match responses to surveys, filter using the tasks, since those have the stableIds - // this is valid since it's currently enforced that all survey responses are done as part of a task, - const matchedTask = enrollee.participantTasks - .find(task => task.targetStableId === configSurvey.survey.stableId) - if (!matchedTask) { - return - } - const matchedResponse = enrollee.surveyResponses - .find(response => matchedTask.surveyResponseId === response.id) - responseMap[configSurvey.survey.stableId] = { - survey: configSurvey, - response: matchedResponse, - task: matchedTask - } - }) const researchSurveys = surveys .filter(survey => survey.survey.surveyType === 'RESEARCH') @@ -70,6 +55,39 @@ export function LoadedEnrolleeView({ enrollee, studyEnvContext, onUpdate }: .filter(survey => survey.survey.surveyType === 'OUTREACH') const consentSurveys = surveys .filter(survey => survey.survey.surveyType === 'CONSENT') + const adminSurveys = surveys + .filter(survey => survey.survey.surveyType === 'ADMIN') + + const updateResponseMap = (stableId: string, response: SurveyResponse) => { + setResponseMap({ + ...responseMap, + [stableId]: { + ...responseMap[stableId], + response + } + }) + } + + useEffect(() => { + const updatedResponseMap: ResponseMapT = {} + surveys.forEach(configSurvey => { + // to match responses to surveys, filter using the tasks, since those have the stableIds + // this is valid since it's currently enforced that all survey responses are done as part of a task, + const matchedTask = enrollee.participantTasks + .find(task => task.targetStableId === configSurvey.survey.stableId) + if (!matchedTask) { + return + } + const matchedResponse = enrollee.surveyResponses + .find(response => matchedTask.surveyResponseId === response.id) + updatedResponseMap[configSurvey.survey.stableId] = { + survey: configSurvey, + response: matchedResponse, + task: matchedTask + } + }) + setResponseMap(updatedResponseMap) + }, [enrollee]) return
    @@ -90,7 +108,7 @@ export function LoadedEnrolleeView({ enrollee, studyEnvContext, onUpdate }:
    • - Overview + Overview
    • Profile @@ -100,7 +118,7 @@ export function LoadedEnrolleeView({ enrollee, studyEnvContext, onUpdate }:
        {currentEnv.preEnrollSurvey &&
      • - PreEnrollment + PreEnrollment
      • } {consentSurveys.map(survey => { @@ -114,7 +132,7 @@ export function LoadedEnrolleeView({ enrollee, studyEnvContext, onUpdate }:
      }/>
    • - {researchSurveys.map(survey => { const stableId = survey.survey.stableId @@ -127,9 +145,29 @@ export function LoadedEnrolleeView({ enrollee, studyEnvContext, onUpdate }:
    } /> +
  • + + {adminSurveys.length === 0 &&
  • + No study staff forms +
  • } + {adminSurveys.map(survey => { + const stableId = survey.survey.stableId + return
  • + {createSurveyNavLink(stableId, responseMap, survey)} + {badgeForResponses(responseMap[stableId]?.response)} +
  • + })} + } + /> +
  • + {outreachSurveys.length === 0 &&
  • + No outreach opportunities +
  • } {outreachSurveys.map(survey => { const stableId = survey.survey.stableId return
  • - {enrollee.kitRequests.length} - + + {enrollee.kitRequests.length} + }
  • @@ -180,11 +217,15 @@ export function LoadedEnrolleeView({ enrollee, studyEnvContext, onUpdate }: onUpdate={onUpdate}/>}/> {currentEnv.preEnrollSurvey && + preEnrollResponse={enrollee.preEnrollmentResponse} + studyEnvContext={studyEnvContext}/> }/>} }/> + responseMap={responseMap} + updateResponseMap={updateResponseMap} + studyEnvContext={studyEnvContext} + onUpdate={onUpdate}/>}/> Unknown participant survey page
  • }/> }/> diff --git a/ui-admin/src/study/participants/participantList/ParticipantList.tsx b/ui-admin/src/study/participants/participantList/ParticipantList.tsx index 756137b389..17beb1d898 100644 --- a/ui-admin/src/study/participants/participantList/ParticipantList.tsx +++ b/ui-admin/src/study/participants/participantList/ParticipantList.tsx @@ -97,14 +97,14 @@ function ParticipantList({ studyEnvContext }: {studyEnvContext: StudyEnvContextT }, { id: 'familyName', header: 'Family name', - accessorKey: 'profile.familyName', + accessorFn: row => row.profile?.familyName, meta: { columnType: 'string' } }, { id: 'givenName', header: 'Given name', - accessorKey: 'profile.givenName', + accessorFn: row => row.profile?.givenName, meta: { columnType: 'string' } diff --git a/ui-admin/src/study/participants/survey/EnrolleeSurveyView.test.tsx b/ui-admin/src/study/participants/survey/EnrolleeSurveyView.test.tsx index aa06cd653f..7120274a8b 100644 --- a/ui-admin/src/study/participants/survey/EnrolleeSurveyView.test.tsx +++ b/ui-admin/src/study/participants/survey/EnrolleeSurveyView.test.tsx @@ -22,13 +22,14 @@ describe('RawEnrolleeSurveyView', () => { ) render(RoutedComponent) - expect(screen.getByText('(version 2)')).toBeInTheDocument() + expect(screen.getByText('(version 2)', { exact: false })).toBeInTheDocument() }) - it('renders the mutliple versions from the answers', async () => { + it('renders the multiple versions from the answers', async () => { const response = { ...mockSurveyResponse(), answers: [ @@ -39,10 +40,11 @@ describe('RawEnrolleeSurveyView', () => { const { RoutedComponent } = setupRouterTest( ) render(RoutedComponent) - expect(screen.getByText('(versions 2, 3)')).toBeInTheDocument() + expect(screen.getByText('(versions 2, 3)', { exact: false })).toBeInTheDocument() }) }) diff --git a/ui-admin/src/study/participants/survey/PrintFormModal.tsx b/ui-admin/src/study/participants/survey/PrintFormModal.tsx index 9b7335355a..78bbda871a 100644 --- a/ui-admin/src/study/participants/survey/PrintFormModal.tsx +++ b/ui-admin/src/study/participants/survey/PrintFormModal.tsx @@ -34,7 +34,8 @@ const PrintFormModal = ({ survey, answers, resumeData }: DownloadFormViewProps) return
    - Done + {/* @ts-ignore Link to type also supports numbers for back operations */} + Done
    diff --git a/ui-admin/src/study/participants/survey/SurveyFullDataView.test.tsx b/ui-admin/src/study/participants/survey/SurveyFullDataView.test.tsx index 3d3887933f..d122f18d10 100644 --- a/ui-admin/src/study/participants/survey/SurveyFullDataView.test.tsx +++ b/ui-admin/src/study/participants/survey/SurveyFullDataView.test.tsx @@ -1,12 +1,9 @@ -import { render, screen, waitFor } from '@testing-library/react' +import { render, screen } from '@testing-library/react' import React from 'react' -import SurveyFullDataView, { getDisplayValue, ItemDisplay } from './SurveyFullDataView' +import { getDisplayValue, ItemDisplay } from './SurveyFullDataView' import { Question } from 'survey-core' import { Answer } from '@juniper/ui-core/build/types/forms' -import { mockStudyEnvContext, mockSurvey } from 'test-utils/mocking-utils' -import userEvent from '@testing-library/user-event' -import { setupRouterTest } from '@juniper/ui-core' describe('getDisplayValue', () => { @@ -85,17 +82,6 @@ describe('getDisplayValue', () => { }) }) -test('shows the download/print modal', async () => { - const printSpy = jest.spyOn(window, 'print').mockImplementation(() => 1) - const { RoutedComponent } = setupRouterTest( - ) - render(RoutedComponent) - expect(screen.queryByText('Done')).not.toBeInTheDocument() - await userEvent.click(screen.getByText('print/download')) - expect(screen.getByText('Done')).toBeVisible() - await waitFor(() => expect(printSpy).toHaveBeenCalledTimes(1)) -}) - describe('ItemDisplay', () => { it('renders the language used to answer a question', async () => { const question = { name: 'testQ', text: 'test question', isVisible: true, getType: () => 'text' } diff --git a/ui-admin/src/study/participants/survey/SurveyFullDataView.tsx b/ui-admin/src/study/participants/survey/SurveyFullDataView.tsx index 3fb7f85fe9..c3ee4d4cbd 100644 --- a/ui-admin/src/study/participants/survey/SurveyFullDataView.tsx +++ b/ui-admin/src/study/participants/survey/SurveyFullDataView.tsx @@ -10,10 +10,8 @@ import { } from '@juniper/ui-core' import Api, { Answer, Survey } from 'api/api' import InfoPopup from 'components/forms/InfoPopup' -import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' -import { faDownload } from '@fortawesome/free-solid-svg-icons' import PrintFormModal from './PrintFormModal' -import { Link, Route, Routes } from 'react-router-dom' +import { Route, Routes } from 'react-router-dom' import { renderTruncatedText } from 'util/pageUtils' import { StudyEnvContextT } from 'study/StudyEnvironmentRouter' @@ -26,8 +24,9 @@ type SurveyFullDataViewProps = { } /** renders every item in a survey response */ -export default function SurveyFullDataView({ answers, resumeData, survey, userId, studyEnvContext }: - SurveyFullDataViewProps) { +export default function SurveyFullDataView({ + answers, resumeData, survey, userId, studyEnvContext +}: SurveyFullDataViewProps) { const [showAllQuestions, setShowAllQuestions] = useState(true) const [showFullQuestions, setShowFullQuestions] = useState(false) const surveyJsData = makeSurveyJsData(resumeData, answers, userId) @@ -65,11 +64,6 @@ export default function SurveyFullDataView({ answers, resumeData, survey, userId
    -
    - - print/download - -

    diff --git a/ui-admin/src/study/participants/survey/SurveyResponseEditor.tsx b/ui-admin/src/study/participants/survey/SurveyResponseEditor.tsx index 416550d81f..c5f520f720 100644 --- a/ui-admin/src/study/participants/survey/SurveyResponseEditor.tsx +++ b/ui-admin/src/study/participants/survey/SurveyResponseEditor.tsx @@ -3,16 +3,19 @@ import { Survey, SurveyResponse } from 'api/api' import DocumentTitle from 'util/DocumentTitle' import _cloneDeep from 'lodash/cloneDeep' -import { Enrollee, PagedSurveyView, useTaskIdParam } from '@juniper/ui-core' +import { AutosaveStatus, Enrollee, PagedSurveyView, useTaskIdParam } from '@juniper/ui-core' import { StudyEnvContextT } from 'study/StudyEnvironmentRouter' import { Store } from 'react-notifications-component' import { failureNotification, successNotification } from 'util/notifications' import { usePortalLanguage } from 'portal/usePortalLanguage' /** allows editing of a survey response */ -export default function SurveyResponseEditor({ studyEnvContext, response, survey, enrollee, adminUserId, onUpdate }: { - studyEnvContext: StudyEnvContextT, response?: SurveyResponse, - survey: Survey, enrollee: Enrollee, adminUserId: string, onUpdate: () => void +export default function SurveyResponseEditor({ + studyEnvContext, response, survey, enrollee, adminUserId, onUpdate, setAutosaveStatus, updateResponseMap +}: { + studyEnvContext: StudyEnvContextT, response?: SurveyResponse, setAutosaveStatus: (status: AutosaveStatus) => void, + survey: Survey, enrollee: Enrollee, adminUserId: string, onUpdate: () => void, + updateResponseMap: (stableId: string, response: SurveyResponse) => void }) { const { defaultLanguage } = usePortalLanguage() const taskId = useTaskIdParam() @@ -25,6 +28,7 @@ export default function SurveyResponseEditor({ studyEnvContext, response, survey envName: studyEnvContext.currentEnv.environmentName, portalShortcode: studyEnvContext.portal.shortcode } + return
    @@ -39,11 +43,13 @@ export default function SurveyResponseEditor({ studyEnvContext, response, survey onUpdate() Store.addNotification(successNotification('Response saved')) }} + updateResponseMap={updateResponseMap} + setAutosaveStatus={setAutosaveStatus} onFailure={() => Store.addNotification(failureNotification('Response could not be saved'))} updateProfile={() => { /*no-op for admins*/ }} updateEnrollee={() => { /*no-op for admins*/ }} taskId={taskId} - showHeaders={true}/> + showHeaders={false}/>
    } diff --git a/ui-admin/src/study/participants/survey/SurveyResponseView.test.tsx b/ui-admin/src/study/participants/survey/SurveyResponseView.test.tsx new file mode 100644 index 0000000000..a0e8b70713 --- /dev/null +++ b/ui-admin/src/study/participants/survey/SurveyResponseView.test.tsx @@ -0,0 +1,57 @@ +import { setupRouterTest } from '@juniper/ui-core' +import { + mockAnswer, + mockConfiguredSurvey, + mockEnrollee, + mockStudyEnvContext, + mockSurveyResponse +} from 'test-utils/mocking-utils' +import { render, screen, waitFor } from '@testing-library/react' +import React from 'react' +import userEvent from '@testing-library/user-event' +import { RawEnrolleeSurveyView } from './SurveyResponseView' + +describe('RawEnrolleeSurveyView', () => { + const mockResponseWithAnswer = { + ...mockSurveyResponse(), + answers: [ + mockAnswer() + ] + } + + test('Printing mode shows the download/print modal', async () => { + const printSpy = jest.spyOn(window, 'print').mockImplementation(() => 1) + const { RoutedComponent } = setupRouterTest( + ) + render(RoutedComponent) + await userEvent.click(screen.getByText('Printing')) + await waitFor(() => expect(screen.getByText('Done')).toBeVisible()) + await waitFor(() => expect(printSpy).toHaveBeenCalledTimes(1)) + }) + + test('Viewing mode shows survey response view', async () => { + const { RoutedComponent } = setupRouterTest( + ) + render(RoutedComponent) + const viewingElements = screen.getAllByText('Viewing') + expect(viewingElements).toHaveLength(2) + await waitFor(() => expect(screen.getByText('Show all questions')).toBeVisible()) + }) + + test('Editing mode shows the survey response editor', async () => { + const { RoutedComponent } = setupRouterTest( + ) + render(RoutedComponent) + await userEvent.click(screen.getByText('Editing')) + await waitFor(() => expect(screen.queryByText('Show all questions')).not.toBeInTheDocument()) + }) +}) diff --git a/ui-admin/src/study/participants/survey/SurveyResponseView.tsx b/ui-admin/src/study/participants/survey/SurveyResponseView.tsx index abfd6f73b9..3eef1c29d9 100644 --- a/ui-admin/src/study/participants/survey/SurveyResponseView.tsx +++ b/ui-admin/src/study/participants/survey/SurveyResponseView.tsx @@ -1,23 +1,37 @@ -import React, { useState } from 'react' +import React, { useEffect, useState } from 'react' import { StudyEnvironmentSurvey, SurveyResponse } from 'api/api' -import { useParams } from 'react-router-dom' +import { useLocation, useNavigate, useParams } from 'react-router-dom' import SurveyFullDataView from './SurveyFullDataView' import SurveyResponseEditor from './SurveyResponseEditor' import { ResponseMapT } from '../enrolleeView/EnrolleeView' import { EnrolleeParams } from '../enrolleeView/useRoutedEnrollee' -import { Enrollee, instantToDefaultString } from '@juniper/ui-core' +import { AutosaveStatus, Enrollee, instantToDefaultString } from '@juniper/ui-core' import DocumentTitle from 'util/DocumentTitle' import _uniq from 'lodash/uniq' import pluralize from 'pluralize' import { StudyEnvContextT } from 'study/StudyEnvironmentRouter' import { useUser } from 'user/UserProvider' import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' -import { faPencil, faX } from '@fortawesome/free-solid-svg-icons' +import { + faCheck, faCircleCheck, + faCircleHalfStroke, + faEye, + faPencil, + faPrint, + faSave, faWarning +} from '@fortawesome/free-solid-svg-icons' +import { Button } from 'components/forms/Button' +import { IconDefinition } from '@fortawesome/fontawesome-svg-core' +import classNames from 'classnames' +import { faCircle as faEmptyCircle } from '@fortawesome/free-regular-svg-icons' /** Show responses for a survey based on url param */ -export default function SurveyResponseView({ enrollee, responseMap, studyEnvContext, onUpdate }: - {enrollee: Enrollee, responseMap: ResponseMapT, studyEnvContext: StudyEnvContextT, onUpdate: () => void}) { +export default function SurveyResponseView({ enrollee, responseMap, updateResponseMap, studyEnvContext, onUpdate }: { + enrollee: Enrollee, responseMap: ResponseMapT, + updateResponseMap: (stableId: string, response: SurveyResponse) => void, + studyEnvContext: StudyEnvContextT, onUpdate: () => void +}) { const params = useParams() const surveyStableId: string | undefined = params.surveyStableId @@ -29,67 +43,165 @@ export default function SurveyResponseView({ enrollee, responseMap, studyEnvCont if (!surveyAndResponses) { return
    This survey has not been assigned to this participant
    } - // key forces the component to be destroyed/remounted when different survey selectect + // key forces the component to be destroyed/remounted when different survey selected return } /** show responses for a survey */ -export function RawEnrolleeSurveyView({ enrollee, configSurvey, response, studyEnvContext, onUpdate }: { - enrollee: Enrollee, configSurvey: StudyEnvironmentSurvey, +export function RawEnrolleeSurveyView({ + enrollee, configSurvey, response, studyEnvContext, onUpdate, + updateResponseMap +}: { + enrollee: Enrollee, configSurvey: StudyEnvironmentSurvey, + updateResponseMap: (stableId: string, response: SurveyResponse) => void, response?: SurveyResponse, studyEnvContext: StudyEnvContextT, onUpdate: () => void }) { - const [isEditing, setIsEditing] = useState(false) const { user } = useUser() - // if this is a dedicated admin form, default to edit mode - if (!configSurvey.survey.allowParticipantStart && configSurvey.survey.allowAdminEdit && user) { - return - } - - if (!response && !configSurvey.survey.allowAdminEdit) { - return
    No response for enrollee {enrollee.shortcode}
    - } - - let versionString = '' - if (response && response.answers.length) { - const answerVersions = _uniq(response.answers.map(ans => ans.surveyVersion)) - versionString = `${pluralize('version', answerVersions.length)} ${answerVersions.join(', ')}` - } + const navigate = useNavigate() + const location = useLocation() + // Admin-only forms should default to edit mode + const [isEditing, setIsEditing] = useState(configSurvey.survey.surveyType === 'ADMIN') + const [autosaveStatus, setAutosaveStatus] = useState() return
    -
    {configSurvey.survey.name}
    +

    {configSurvey.survey.name}

    - - {response && <>{response.complete ? 'Completed' : 'Last updated'} -   {instantToDefaultString(response.createdAt)} - -   - ({versionString}) } - - - { configSurvey.survey.allowAdminEdit && } +
    +
    {surveyTaskStatus(response)}
    +
    + +
    + +
    + setIsEditing(false)} + icon={faEye} + label="Viewing" + description="Read form responses" + /> +
    + setIsEditing(true)} + icon={faPencil} + disabled={!configSurvey.survey.allowAdminEdit} + label="Editing" + description="Edit form responses directly" + /> +
    + { + setIsEditing(false) + navigate(`print${location.search}`) + }} + disabled={!response?.answers.length} + icon={faPrint} + label="Printing" + description="Print or download the form" + /> +
    +
    +
    +

    - {(!isEditing && !response?.answers.length) &&
    - No response yet -
    } + {(!isEditing && !response?.answers.length) &&
    No response for enrollee {enrollee.shortcode}
    } {(!isEditing && response?.answers.length) && } + userId={enrollee.participantUserId} + studyEnvContext={studyEnvContext}/>} {isEditing && user && }
    } + +function surveyTaskStatus(surveyResponse?: SurveyResponse) { + let versionString = '' + if (surveyResponse && surveyResponse.answers.length) { + const answerVersions = _uniq(surveyResponse.answers.map(ans => ans.surveyVersion)) + versionString = `${pluralize('version', answerVersions.length)} ${answerVersions.join(', ')}` + } + + if (!surveyResponse) { + return Not Started + } + return
    + {surveyResponse.complete ? + + Complete + : + + In Progress + } +
    + {surveyResponse.complete? + 'Completed' : 'Last Updated'} {instantToDefaultString(surveyResponse.createdAt)} ({versionString}) +
    +} + +type DropdownButtonProps = { + onClick: () => void, + icon?: IconDefinition, + label: string, + disabled?: boolean, + description?: string +} + +const DropdownButton = (props: DropdownButtonProps) => { + const { onClick, icon, label, disabled, description } = props + return ( + + ) +} + +const AutosaveStatusIndicator = ({ status }: { status?: AutosaveStatus }) => { + const [displayStatus, setDisplayStatus] = useState(status) + + useEffect(() => { + if (status) { + setDisplayStatus(status) + } + if (status === 'SAVING') { + setTimeout(() => setDisplayStatus(undefined), 1000) + } else if (status === 'SAVED') { + setTimeout(() => setDisplayStatus(undefined), 3000) + } + }, [status]) + + return <> + {(displayStatus === 'SAVING') && + Autosaving...} + {(displayStatus === 'SAVED') && + Response saved} + {(displayStatus === 'ERROR') && + Error saving response} + +} diff --git a/ui-admin/src/study/surveys/CreateSurveyModal.tsx b/ui-admin/src/study/surveys/CreateSurveyModal.tsx index 7db01cbd92..2e4afe3d56 100644 --- a/ui-admin/src/study/surveys/CreateSurveyModal.tsx +++ b/ui-admin/src/study/surveys/CreateSurveyModal.tsx @@ -35,8 +35,9 @@ const CreateSurveyModal = ({ studyEnvContext, onDismiss, type }: const [form, setForm] = useState({ ...defaultSurvey, autoUpdateTaskAssignments: type === 'OUTREACH', - assignToExistingEnrollees: type === 'OUTREACH', - allowParticipantReedit: type !== 'CONSENT', + assignToExistingEnrollees: type === 'OUTREACH' || type === 'ADMIN', + allowParticipantReedit: type !== 'CONSENT' && type !== 'ADMIN', + allowParticipantStart: type !== 'ADMIN', allowAdminEdit: type !== 'CONSENT', required: type === 'CONSENT', stableId: '', @@ -94,7 +95,7 @@ const CreateSurveyModal = ({ studyEnvContext, onDismiss, type }: {StableIdInput} { setForm({ ...form, ...updates }) })} diff --git a/ui-admin/src/study/surveys/FormOptions.test.tsx b/ui-admin/src/study/surveys/FormOptions.test.tsx index b7956f5d95..7e495543eb 100644 --- a/ui-admin/src/study/surveys/FormOptions.test.tsx +++ b/ui-admin/src/study/surveys/FormOptions.test.tsx @@ -46,4 +46,17 @@ describe('FormOptions', () => { autoUpdateTaskAssignments: true }) }) + + test('admin forms should be admin-editable by default', async () => { + const updateWorkingForm = jest.fn() + render() + + //we don't display this option, because it's assumed to be true for admin forms + expect(screen.queryByText('Allow study staff to edit participant responses')).not.toBeInTheDocument() + }) }) diff --git a/ui-admin/src/study/surveys/FormOptionsModal.tsx b/ui-admin/src/study/surveys/FormOptionsModal.tsx index 7cbbf8173e..1fa1eb8cfc 100644 --- a/ui-admin/src/study/surveys/FormOptionsModal.tsx +++ b/ui-admin/src/study/surveys/FormOptionsModal.tsx @@ -37,7 +37,8 @@ export default function FormOptionsModal({
    - +
    Note: you must "Save" the form @@ -45,7 +46,8 @@ export default function FormOptionsModal({
    - @@ -55,18 +57,18 @@ export default function FormOptionsModal({ /** * Renders the 'options' for a form, e.g. who is allowed to take it, if it's required, how it's assigned, etc... */ -export const FormOptions = ({ studyEnvContext, workingForm, updateWorkingForm }: +export const FormOptions = ({ studyEnvContext, initialWorkingForm, updateWorkingForm }: { studyEnvContext: StudyEnvContextT, - workingForm: VersionedForm, + initialWorkingForm: VersionedForm, updateWorkingForm: (props: SaveableFormProps) => void }) => { - const isSurvey = !!(workingForm as Survey).surveyType + const workingForm = initialWorkingForm as Survey const { user } = useUser() return <> - {isSurvey && + {(workingForm && !!workingForm.surveyType) &&
    Survey options See the Options Configuration section in @@ -76,47 +78,41 @@ export const FormOptions = ({ studyEnvContext, workingForm, updateWorkingForm }:
    + { (workingForm.surveyType !== 'ADMIN' && workingForm.surveyType !== 'OUTREACH') && - + /> Allow study staff to edit participant responses + } Eligibility Rule

    Use a @@ -124,14 +120,14 @@ export const FormOptions = ({ studyEnvContext, workingForm, updateWorkingForm }: to conditionally assign the survey.

    {userHasPermission(user, studyEnvContext.portal.id, 'prototype_tester') - &&
    - updateWorkingForm({ - ...workingForm, eligibilityRule: exp - })} - searchExpression={(workingForm as Survey).eligibilityRule || ''}/> -
    } + &&
    + updateWorkingForm({ + ...workingForm, eligibilityRule: exp + })} + searchExpression={(workingForm as Survey).eligibilityRule || ''}/> +
    } { updateWorkingForm({ @@ -142,7 +138,7 @@ export const FormOptions = ({ studyEnvContext, workingForm, updateWorkingForm }:
    } - {!isSurvey && <> + {!workingForm.surveyType && <> This form has no configurable options } diff --git a/ui-admin/src/test-utils/mocking-utils.tsx b/ui-admin/src/test-utils/mocking-utils.tsx index dd6546a610..4772b5c44c 100644 --- a/ui-admin/src/test-utils/mocking-utils.tsx +++ b/ui-admin/src/test-utils/mocking-utils.tsx @@ -27,9 +27,8 @@ import { ParticipantNote, ParticipantTask, ParticipantTaskStatus, - ParticipantTaskType, - StudyEnvParams, - Survey + ParticipantTaskType, StudyEnvParams, + Survey, SurveyType } from '@juniper/ui-core' import _times from 'lodash/times' @@ -103,7 +102,7 @@ export const mockPortalEnvironment: (envName: string) => PortalEnvironment = (en /** returns a simple survey object for use/extension in tests */ -export const mockSurvey: () => Survey = () => ({ +export const mockSurvey: (surveyType?: SurveyType) => Survey = (surveyType = 'RESEARCH') => ({ ...defaultSurvey, id: 'surveyId1', stableId: 'survey1', @@ -112,7 +111,7 @@ export const mockSurvey: () => Survey = () => ({ name: 'Survey number one', lastUpdatedAt: 0, createdAt: 0, - surveyType: 'RESEARCH' + surveyType }) /** returns a mock portal study */ diff --git a/ui-admin/src/util/tableUtils.tsx b/ui-admin/src/util/tableUtils.tsx index 5417a3a509..4909ffcc7e 100644 --- a/ui-admin/src/util/tableUtils.tsx +++ b/ui-admin/src/util/tableUtils.tsx @@ -238,11 +238,9 @@ export function ColumnVisibilityControl({ table }: {table: Table}) {
    @@ -253,11 +251,9 @@ export function ColumnVisibilityControl({ table }: {table: Table}) {
    diff --git a/ui-core/src/components/forms/PagedSurveyView.test.tsx b/ui-core/src/components/forms/PagedSurveyView.test.tsx index 83a18d989b..1dbd387479 100644 --- a/ui-core/src/components/forms/PagedSurveyView.test.tsx +++ b/ui-core/src/components/forms/PagedSurveyView.test.tsx @@ -247,7 +247,8 @@ const setupSurveyTest = (survey: Survey, profile?: Profile) => { ) diff --git a/ui-core/src/components/forms/PagedSurveyView.tsx b/ui-core/src/components/forms/PagedSurveyView.tsx index 0dc5bacae9..5f9874dfab 100644 --- a/ui-core/src/components/forms/PagedSurveyView.tsx +++ b/ui-core/src/components/forms/PagedSurveyView.tsx @@ -20,14 +20,19 @@ import { Enrollee, Profile } from 'src/types/user' const AUTO_SAVE_INTERVAL = 3 * 1000 // auto-save every 3 seconds if there are changes +export type AutosaveStatus = 'SAVING' | 'SAVED' | 'ERROR' + /** handles paging the form */ export function PagedSurveyView({ + updateResponseMap, studyEnvParams, form, response, updateEnrollee, updateProfile, taskId, selectedLanguage, - enrollee, proxyProfile, adminUserId, onSuccess, onFailure, showHeaders = true + setAutosaveStatus, enrollee, proxyProfile, adminUserId, onSuccess, onFailure, showHeaders = true }: { studyEnvParams: StudyEnvParams, form: Survey, response: SurveyResponse, + updateResponseMap: (stableId: string, response: SurveyResponse) => void onSuccess: () => void, onFailure: () => void, selectedLanguage: string, + setAutosaveStatus: (status: AutosaveStatus) => void, updateEnrollee: (enrollee: Enrollee, updateWithoutRerender?: boolean) => void, updateProfile: (profile: Profile, updateWithoutRerender?: boolean) => void, proxyProfile?: Profile, @@ -87,6 +92,7 @@ export function PagedSurveyView({ // don't bother saving if there are no changes return } + setAutosaveStatus('SAVING') const prevPrevSave = prevSave.current prevSave.current = currentModelValues @@ -120,9 +126,12 @@ export function PagedSurveyView({ */ updateEnrollee(updatedEnrollee, true) lastAutoSaveErrored.current = false + setAutosaveStatus('SAVED') + updateResponseMap(form.stableId, response.response) }).catch(() => { // if the operation fails, restore the state from before so the next diff operation will capture the changes // that failed to save this time + setAutosaveStatus('ERROR') prevSave.current = prevPrevSave lastAutoSaveErrored.current = true }) @@ -134,8 +143,8 @@ export function PagedSurveyView({ <> {/* f3f3f3 background is to match surveyJs "modern" theme */}
    - {showHeaders && } - {showHeaders && } + + {showHeaders &&

    {i18n(`${form.stableId}:${form.version}`, { defaultValue: form.name })}

    } diff --git a/ui-core/src/index.ts b/ui-core/src/index.ts index 1cfcd17565..523ba899a5 100644 --- a/ui-core/src/index.ts +++ b/ui-core/src/index.ts @@ -4,7 +4,7 @@ export { PrivacyPolicy } from './terms/PrivacyPolicy' export { SuggestBetterAddressModal } from './components/SuggestBetterAddressModal' export { EditAddress } from './components/EditAddress' -export { PagedSurveyView } from './components/forms/PagedSurveyView' +export * from './components/forms/PagedSurveyView' export { SurveyAutoCompleteButton } from './components/forms/SurveyAutoCompleteButton' export { SurveyReviewModeButton } from './components/forms/ReviewModeButton' export { createAddressValidator } from './surveyjs/address-validator' diff --git a/ui-core/src/types/forms.ts b/ui-core/src/types/forms.ts index 9ac7c8a1fd..1702e8375b 100644 --- a/ui-core/src/types/forms.ts +++ b/ui-core/src/types/forms.ts @@ -28,7 +28,7 @@ export type VersionedForm = { footer?: string } -export type SurveyType = 'RESEARCH' | 'OUTREACH' | 'CONSENT' +export type SurveyType = 'RESEARCH' | 'OUTREACH' | 'CONSENT' | 'ADMIN' export type Survey = VersionedForm & { surveyType: SurveyType diff --git a/ui-core/src/types/task.ts b/ui-core/src/types/task.ts index 00b7c29d09..8fac7e86dd 100644 --- a/ui-core/src/types/task.ts +++ b/ui-core/src/types/task.ts @@ -32,5 +32,6 @@ export type ParticipantTaskType = | 'SURVEY' | 'OUTREACH' | 'KIT_REQUEST' + | 'ADMIN_FORM' export {} diff --git a/ui-participant/src/hub/survey/SurveyView.tsx b/ui-participant/src/hub/survey/SurveyView.tsx index a161021494..25b04fde39 100644 --- a/ui-participant/src/hub/survey/SurveyView.tsx +++ b/ui-participant/src/hub/survey/SurveyView.tsx @@ -90,9 +90,11 @@ function SurveyView({ showHeaders = true }: { showHeaders?: boolean }) { studyEnvParams={studyEnvParams} form={formAndResponses.studyEnvironmentSurvey.survey} enrollee={enrollee} + updateResponseMap={() => { /* no-op */ }} proxyProfile={proxyProfile} response={formAndResponses.surveyResponse} selectedLanguage={selectedLanguage} + setAutosaveStatus={() => { /* no-op */ }} adminUserId={null} onSuccess={onSuccess} onFailure={onFailure}