From cbaa12c21770da669145f033dfe855c2d40e47ae Mon Sep 17 00:00:00 2001 From: Matt Bemis Date: Tue, 4 Jun 2024 09:05:10 -0400 Subject: [PATCH 01/12] admin form ux --- .../pearl/core/model/survey/SurveyType.java | 3 +- .../pearl/core/model/workflow/TaskType.java | 3 +- .../service/survey/SurveyTaskDispatcher.java | 5 +- ui-admin/src/study/StudyContent.tsx | 81 +++++--- .../enrolleeView/EnrolleeView.tsx | 51 +++-- .../participantList/ParticipantList.tsx | 6 +- .../participants/survey/PrintFormModal.tsx | 3 +- .../survey/SurveyFullDataView.tsx | 9 +- .../survey/SurveyResponseEditor.tsx | 12 +- .../survey/SurveyResponseView.tsx | 186 +++++++++++++----- .../src/study/surveys/CreateSurveyModal.tsx | 7 +- .../src/study/surveys/FormOptionsModal.tsx | 68 +++++-- ui-admin/src/util/tableUtils.tsx | 16 +- .../components/forms/PagedSurveyView.test.tsx | 2 +- .../src/components/forms/PagedSurveyView.tsx | 12 +- ui-core/src/index.ts | 2 +- ui-core/src/types/forms.ts | 2 +- ui-participant/src/hub/survey/SurveyView.tsx | 1 + 18 files changed, 322 insertions(+), 147 deletions(-) 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..997d5d6037 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 // a task for study staff to complete -- not visible to participants } 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..9e9ee671a0 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 @@ -185,7 +185,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 +248,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 ); /** diff --git a/ui-admin/src/study/StudyContent.tsx b/ui-admin/src/study/StudyContent.tsx index 8d61512a62..ca42f43881 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 getUniqueStableIds(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 = getUniqueStableIds(configuredSurveys, 'RESEARCH') + const outreachSurveyStableIds = getUniqueStableIds(configuredSurveys, 'OUTREACH') + const consentSurveyStableIds = getUniqueStableIds(configuredSurveys, 'CONSENT') + const adminFormStableIds = getUniqueStableIds(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..e45dcdbb1d 100644 --- a/ui-admin/src/study/participants/enrolleeView/EnrolleeView.tsx +++ b/ui-admin/src/study/participants/enrolleeView/EnrolleeView.tsx @@ -35,14 +35,15 @@ 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 surveys: StudyEnvironmentSurvey[] = currentEnv.configuredSurveys @@ -70,6 +71,8 @@ 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') return
    @@ -90,7 +93,7 @@ export function LoadedEnrolleeView({ enrollee, studyEnvContext, onUpdate }:
    • - Overview + Overview
    • Profile @@ -100,7 +103,7 @@ export function LoadedEnrolleeView({ enrollee, studyEnvContext, onUpdate }:
        {currentEnv.preEnrollSurvey &&
      • - PreEnrollment + PreEnrollment
      • } {consentSurveys.map(survey => { @@ -114,7 +117,7 @@ export function LoadedEnrolleeView({ enrollee, studyEnvContext, onUpdate }:
      }/>
    • - {researchSurveys.map(survey => { const stableId = survey.survey.stableId @@ -127,9 +130,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 +202,14 @@ export function LoadedEnrolleeView({ enrollee, studyEnvContext, onUpdate }: onUpdate={onUpdate}/>}/> {currentEnv.preEnrollSurvey && + preEnrollResponse={enrollee.preEnrollmentResponse} + studyEnvContext={studyEnvContext}/> }/>} }/> + responseMap={responseMap} + 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 7bf0bc81db..c580f7800b 100644 --- a/ui-admin/src/study/participants/participantList/ParticipantList.tsx +++ b/ui-admin/src/study/participants/participantList/ParticipantList.tsx @@ -91,7 +91,7 @@ function ParticipantList({ studyEnvContext }: {studyEnvContext: StudyEnvContextT }, { id: 'lastLogin', header: 'Last login', - accessorKey: 'participantUser.lastLogin', + accessorFn: row => row.participantUser?.lastLogin, enableColumnFilter: false, meta: { columnType: 'instant' @@ -100,14 +100,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/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.tsx b/ui-admin/src/study/participants/survey/SurveyFullDataView.tsx index 3fb7f85fe9..87fc158fb5 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' @@ -65,11 +63,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..a78585bc28 100644 --- a/ui-admin/src/study/participants/survey/SurveyResponseEditor.tsx +++ b/ui-admin/src/study/participants/survey/SurveyResponseEditor.tsx @@ -3,15 +3,17 @@ 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, +export default function SurveyResponseEditor({ + studyEnvContext, response, survey, enrollee, adminUserId, onUpdate, setAutosaveStatus +}: { + studyEnvContext: StudyEnvContextT, response?: SurveyResponse, setAutosaveStatus: (status: AutosaveStatus) => void, survey: Survey, enrollee: Enrollee, adminUserId: string, onUpdate: () => void }) { const { defaultLanguage } = usePortalLanguage() @@ -25,6 +27,7 @@ export default function SurveyResponseEditor({ studyEnvContext, response, survey envName: studyEnvContext.currentEnv.environmentName, portalShortcode: studyEnvContext.portal.shortcode } + return
    @@ -39,11 +42,12 @@ export default function SurveyResponseEditor({ studyEnvContext, response, survey onUpdate() Store.addNotification(successNotification('Response saved')) }} + 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.tsx b/ui-admin/src/study/participants/survey/SurveyResponseView.tsx index abfd6f73b9..40b5c53fc4 100644 --- a/ui-admin/src/study/participants/survey/SurveyResponseView.tsx +++ b/ui-admin/src/study/participants/survey/SurveyResponseView.tsx @@ -1,23 +1,35 @@ -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 +} 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, studyEnvContext, onUpdate }: { + enrollee: Enrollee, responseMap: ResponseMapT, studyEnvContext: StudyEnvContextT, onUpdate: () => void +}) { const params = useParams() const surveyStableId: string | undefined = params.surveyStableId @@ -29,67 +41,147 @@ 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, + enrollee: Enrollee, configSurvey: StudyEnvironmentSurvey, response?: SurveyResponse, studyEnvContext: StudyEnvContextT, onUpdate: () => void }) { - const [isEditing, setIsEditing] = useState(false) + const [isEditing, setIsEditing] = useState(configSurvey.survey.surveyType === 'ADMIN') const { user } = useUser() - // if this is a dedicated admin form, default to edit mode - if (!configSurvey.survey.allowParticipantStart && configSurvey.survey.allowAdminEdit && user) { - return - } + const navigate = useNavigate() + const location = useLocation() + const [autosaveStatus, setAutosaveStatus] = useState() + const [displayStatus, setDisplayStatus] = useState() - 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(', ')}` - } + useEffect(() => { + if (autosaveStatus === 'saving') { + setDisplayStatus('saving') + setTimeout(() => setDisplayStatus(undefined), 1000) + } else if (autosaveStatus === 'saved') { + setDisplayStatus('saved') + setTimeout(() => setDisplayStatus(undefined), 3000) + } + }, [autosaveStatus]) return
    -
    {configSurvey.survey.name}
    +

    {configSurvey.survey.name}

    - - {response && <>{response.complete ? 'Completed' : 'Last updated'} -   {instantToDefaultString(response.createdAt)} - -   - ({versionString}) } - - - { configSurvey.survey.allowAdminEdit && } +
    +
    {surveyTaskStatus(response)}
    +
    + {(displayStatus === 'saving') && + Autosaving...} + {(displayStatus === 'saved') && + Response saved} +
    + +
    + 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}`) + }} + 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 ( + + ) +} diff --git a/ui-admin/src/study/surveys/CreateSurveyModal.tsx b/ui-admin/src/study/surveys/CreateSurveyModal.tsx index 7db01cbd92..2f95b62d1f 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: '', @@ -139,7 +140,7 @@ const CreateSurveyModal = ({ studyEnvContext, onDismiss, type }: @@ -53,6 +55,34 @@ export const FormOptions = ({ studyEnvContext, workingForm, updateWorkingForm }: }) => { const isSurvey = !!(workingForm as Survey).surveyType + const calculateFormMode = () => { + if ((workingForm as Survey).surveyType === 'ADMIN') { + return 'ADMIN' + } else if ((workingForm as Survey).allowParticipantStart) { + return 'PARTICIPANT' + } else { + return 'ALL' + } + } + + const [selectedFormMode, setSelectedFormMode] = useState(calculateFormMode()) + + useEffect(() => { + if (selectedFormMode === 'ADMIN') { + updateWorkingForm({ + ...workingForm, allowAdminEdit: true, allowParticipantStart: false + }) + } else if (selectedFormMode === 'PARTICIPANT') { + updateWorkingForm({ + ...workingForm, allowAdminEdit: false, allowParticipantStart: true + }) + } else { + updateWorkingForm({ + ...workingForm, allowAdminEdit: true, allowParticipantStart: true + }) + } + }, [selectedFormMode]) + const { user } = useUser() return <> @@ -93,21 +123,23 @@ export const FormOptions = ({ studyEnvContext, workingForm, updateWorkingForm }: })} /> Auto-update participant tasks to the latest version of this survey after publishing - - - Eligibility Rule + { (selectedFormMode !== 'ADMIN') && <> +
    Who can complete and edit responses for this form?
    +
    + + +
    } +
    Eligibility Rule
    {userHasPermission(user, studyEnvContext.portal.id, 'prototype_tester') &&
    ({ 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..4a066a74ff 100644 --- a/ui-core/src/components/forms/PagedSurveyView.test.tsx +++ b/ui-core/src/components/forms/PagedSurveyView.test.tsx @@ -247,7 +247,7 @@ 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..b47ac3e886 100644 --- a/ui-core/src/components/forms/PagedSurveyView.tsx +++ b/ui-core/src/components/forms/PagedSurveyView.tsx @@ -20,14 +20,17 @@ 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({ 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, 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 +90,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 +124,11 @@ export function PagedSurveyView({ */ updateEnrollee(updatedEnrollee, true) lastAutoSaveErrored.current = false + setAutosaveStatus('saved') }).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 +140,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-participant/src/hub/survey/SurveyView.tsx b/ui-participant/src/hub/survey/SurveyView.tsx index a161021494..19962bba25 100644 --- a/ui-participant/src/hub/survey/SurveyView.tsx +++ b/ui-participant/src/hub/survey/SurveyView.tsx @@ -93,6 +93,7 @@ function SurveyView({ showHeaders = true }: { showHeaders?: boolean }) { proxyProfile={proxyProfile} response={formAndResponses.surveyResponse} selectedLanguage={selectedLanguage} + setAutosaveStatus={() => { /* no-op */ }} adminUserId={null} onSuccess={onSuccess} onFailure={onFailure} From a234127c5c0078da056f383b9aa7b45e0264b9d7 Mon Sep 17 00:00:00 2001 From: Matt Bemis Date: Tue, 4 Jun 2024 12:55:44 -0400 Subject: [PATCH 02/12] tests --- .../survey/EnrolleeSurveyView.test.tsx | 6 +-- .../survey/SurveyFullDataView.test.tsx | 18 +-------- .../survey/SurveyResponseView.test.tsx | 38 +++++++++++++++++++ .../survey/SurveyResponseView.tsx | 4 +- .../study/surveys/CreateSurveyModal.test.tsx | 2 + 5 files changed, 48 insertions(+), 20 deletions(-) create mode 100644 ui-admin/src/study/participants/survey/SurveyResponseView.test.tsx diff --git a/ui-admin/src/study/participants/survey/EnrolleeSurveyView.test.tsx b/ui-admin/src/study/participants/survey/EnrolleeSurveyView.test.tsx index aa06cd653f..a69249548b 100644 --- a/ui-admin/src/study/participants/survey/EnrolleeSurveyView.test.tsx +++ b/ui-admin/src/study/participants/survey/EnrolleeSurveyView.test.tsx @@ -25,10 +25,10 @@ describe('RawEnrolleeSurveyView', () => { onUpdate={jest.fn()} response={response}/>) 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: [ @@ -43,6 +43,6 @@ describe('RawEnrolleeSurveyView', () => { onUpdate={jest.fn()} response={response}/>) 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/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/SurveyResponseView.test.tsx b/ui-admin/src/study/participants/survey/SurveyResponseView.test.tsx new file mode 100644 index 0000000000..d980a424d7 --- /dev/null +++ b/ui-admin/src/study/participants/survey/SurveyResponseView.test.tsx @@ -0,0 +1,38 @@ +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', () => { + test('shows the download/print modal', async () => { + const enrollee = { + ...mockEnrollee(), + surveyResponses: [ + { + ...mockSurveyResponse(), + answers: [ + mockAnswer() + ] + } + ] + } + 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)) + }) +}) + +//configSurvey={{ ...mockConfiguredSurvey(), survey: { ...mockSurvey(), surveyType: 'ADMIN'}}} onUpdate={jest.fn()}/>) diff --git a/ui-admin/src/study/participants/survey/SurveyResponseView.tsx b/ui-admin/src/study/participants/survey/SurveyResponseView.tsx index 40b5c53fc4..9903dfd293 100644 --- a/ui-admin/src/study/participants/survey/SurveyResponseView.tsx +++ b/ui-admin/src/study/participants/survey/SurveyResponseView.tsx @@ -51,10 +51,11 @@ export function RawEnrolleeSurveyView({ enrollee, configSurvey, response, studyE enrollee: Enrollee, configSurvey: StudyEnvironmentSurvey, response?: SurveyResponse, studyEnvContext: StudyEnvContextT, onUpdate: () => void }) { - const [isEditing, setIsEditing] = useState(configSurvey.survey.surveyType === 'ADMIN') const { user } = useUser() 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() const [displayStatus, setDisplayStatus] = useState() @@ -116,6 +117,7 @@ export function RawEnrolleeSurveyView({ enrollee, configSurvey, response, studyE setIsEditing(false) navigate(`print${location.search}`) }} + disabled={!response?.answers.length} icon={faPrint} label="Printing" description="Print or download the form" diff --git a/ui-admin/src/study/surveys/CreateSurveyModal.test.tsx b/ui-admin/src/study/surveys/CreateSurveyModal.test.tsx index c7d6db0e7f..dfaef5c712 100644 --- a/ui-admin/src/study/surveys/CreateSurveyModal.test.tsx +++ b/ui-admin/src/study/surveys/CreateSurveyModal.test.tsx @@ -96,6 +96,7 @@ describe('CreateSurveyModal', () => { expect(Api.createNewSurvey).toHaveBeenCalledWith(studyEnvContext.portal.shortcode, { ...defaultSurvey, + allowAdminEdit: false, autoUpdateTaskAssignments: true, assignToExistingEnrollees: true, blurb: 'Testing out the marketing blurb...', @@ -137,6 +138,7 @@ describe('CreateSurveyModal', () => { expect(Api.createNewSurvey).toHaveBeenCalledWith(studyEnvContext.portal.shortcode, { ...defaultSurvey, + allowAdminEdit: false, autoUpdateTaskAssignments: true, blurb: 'Testing out the screener blurb...', assignToExistingEnrollees: true, From efcefbe232a5880a98a457a1ffc81cca8aa697c6 Mon Sep 17 00:00:00 2001 From: Matt Bemis Date: Tue, 4 Jun 2024 14:20:40 -0400 Subject: [PATCH 03/12] simplify ux and code --- ui-admin/src/study/StudyContent.tsx | 10 +-- .../src/study/surveys/FormOptionsModal.tsx | 67 +++++-------------- 2 files changed, 20 insertions(+), 57 deletions(-) diff --git a/ui-admin/src/study/StudyContent.tsx b/ui-admin/src/study/StudyContent.tsx index ca42f43881..d726bc54e6 100644 --- a/ui-admin/src/study/StudyContent.tsx +++ b/ui-admin/src/study/StudyContent.tsx @@ -53,17 +53,17 @@ function StudyContent({ studyEnvContext }: {studyEnvContext: StudyEnvContextT}) }, { setIsLoading }) } - function getUniqueStableIds(configuredSurveys: StudyEnvironmentSurveyNamed[], surveyType: string) { + 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 = getUniqueStableIds(configuredSurveys, 'RESEARCH') - const outreachSurveyStableIds = getUniqueStableIds(configuredSurveys, 'OUTREACH') - const consentSurveyStableIds = getUniqueStableIds(configuredSurveys, 'CONSENT') - const adminFormStableIds = getUniqueStableIds(configuredSurveys, 'ADMIN') + const researchSurveyStableIds = getUniqueStableIdsForType(configuredSurveys, 'RESEARCH') + const outreachSurveyStableIds = getUniqueStableIdsForType(configuredSurveys, 'OUTREACH') + const consentSurveyStableIds = getUniqueStableIdsForType(configuredSurveys, 'CONSENT') + const adminFormStableIds = getUniqueStableIdsForType(configuredSurveys, 'ADMIN') return
    { renderPageHeader('Forms & Surveys') } diff --git a/ui-admin/src/study/surveys/FormOptionsModal.tsx b/ui-admin/src/study/surveys/FormOptionsModal.tsx index bd2128c4a9..a27dc0b31f 100644 --- a/ui-admin/src/study/surveys/FormOptionsModal.tsx +++ b/ui-admin/src/study/surveys/FormOptionsModal.tsx @@ -1,8 +1,8 @@ -import React, { useEffect, useState } from 'react' +import React from 'react' import { Survey, VersionedForm } from 'api/api' import Modal from 'react-bootstrap/Modal' import { Button } from 'components/forms/Button' -import { faArrowRight, faUser } from '@fortawesome/free-solid-svg-icons' +import { faArrowRight } from '@fortawesome/free-solid-svg-icons' import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' import { SaveableFormProps } from './SurveyView' import { DocsKey, ZendeskLink } from 'util/zendeskUtils' @@ -10,7 +10,6 @@ import InfoPopup from 'components/forms/InfoPopup' import { SearchQueryBuilder } from '../../search/SearchQueryBuilder' import { StudyEnvContextT } from '../StudyEnvironmentRouter' import { userHasPermission, useUser } from '../../user/UserProvider' -import { faUsers } from '@fortawesome/free-solid-svg-icons/faUsers' /** component for selecting versions of a form */ export default function FormOptionsModal({ @@ -55,34 +54,6 @@ export const FormOptions = ({ studyEnvContext, workingForm, updateWorkingForm }: }) => { const isSurvey = !!(workingForm as Survey).surveyType - const calculateFormMode = () => { - if ((workingForm as Survey).surveyType === 'ADMIN') { - return 'ADMIN' - } else if ((workingForm as Survey).allowParticipantStart) { - return 'PARTICIPANT' - } else { - return 'ALL' - } - } - - const [selectedFormMode, setSelectedFormMode] = useState(calculateFormMode()) - - useEffect(() => { - if (selectedFormMode === 'ADMIN') { - updateWorkingForm({ - ...workingForm, allowAdminEdit: true, allowParticipantStart: false - }) - } else if (selectedFormMode === 'PARTICIPANT') { - updateWorkingForm({ - ...workingForm, allowAdminEdit: false, allowParticipantStart: true - }) - } else { - updateWorkingForm({ - ...workingForm, allowAdminEdit: true, allowParticipantStart: true - }) - } - }, [selectedFormMode]) - const { user } = useUser() return <> @@ -123,29 +94,21 @@ export const FormOptions = ({ studyEnvContext, workingForm, updateWorkingForm }: })} /> Auto-update participant tasks to the latest version of this survey after publishing - { (selectedFormMode !== 'ADMIN') && <> -
    Who can complete and edit responses for this form?
    -
    - - -
    } + { ((workingForm as Survey).surveyType !== 'ADMIN') && + }
    Eligibility Rule
    {userHasPermission(user, studyEnvContext.portal.id, 'prototype_tester') - &&
    updateWorkingForm({ - ...workingForm, eligibilityRule: exp - })}/>
    } + &&
    updateWorkingForm({ + ...workingForm, eligibilityRule: exp + })}/>
    } { From dc22c17ae6773bca8402ad624b0800bbb197404e Mon Sep 17 00:00:00 2001 From: Matt Bemis Date: Tue, 4 Jun 2024 14:36:55 -0400 Subject: [PATCH 04/12] cleanup --- .../src/study/surveys/CreateSurveyModal.tsx | 4 +-- .../src/study/surveys/FormOptionsModal.tsx | 27 ++++++++++--------- 2 files changed, 16 insertions(+), 15 deletions(-) diff --git a/ui-admin/src/study/surveys/CreateSurveyModal.tsx b/ui-admin/src/study/surveys/CreateSurveyModal.tsx index 2f95b62d1f..2e4afe3d56 100644 --- a/ui-admin/src/study/surveys/CreateSurveyModal.tsx +++ b/ui-admin/src/study/surveys/CreateSurveyModal.tsx @@ -95,7 +95,7 @@ const CreateSurveyModal = ({ studyEnvContext, onDismiss, type }: {StableIdInput} { setForm({ ...form, ...updates }) })} @@ -140,7 +140,7 @@ const CreateSurveyModal = ({ studyEnvContext, onDismiss, type }: ) } + +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-core/src/components/forms/PagedSurveyView.tsx b/ui-core/src/components/forms/PagedSurveyView.tsx index b47ac3e886..36ba6a8fa9 100644 --- a/ui-core/src/components/forms/PagedSurveyView.tsx +++ b/ui-core/src/components/forms/PagedSurveyView.tsx @@ -20,7 +20,7 @@ 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' +export type AutosaveStatus = 'SAVING' | 'SAVED' | 'ERROR' /** handles paging the form */ export function PagedSurveyView({ @@ -90,7 +90,7 @@ export function PagedSurveyView({ // don't bother saving if there are no changes return } - setAutosaveStatus('saving') + setAutosaveStatus('SAVING') const prevPrevSave = prevSave.current prevSave.current = currentModelValues @@ -124,11 +124,11 @@ export function PagedSurveyView({ */ updateEnrollee(updatedEnrollee, true) lastAutoSaveErrored.current = false - setAutosaveStatus('saved') + setAutosaveStatus('SAVED') }).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') + setAutosaveStatus('ERROR') prevSave.current = prevPrevSave lastAutoSaveErrored.current = true }) From 062ed84794f8cd521f1d152de21570d14ee8f86a Mon Sep 17 00:00:00 2001 From: Matt Bemis Date: Wed, 5 Jun 2024 16:29:50 -0400 Subject: [PATCH 09/12] messy fix --- .../service/survey/SurveyResponseService.java | 4 ++ .../enrolleeView/EnrolleeView.tsx | 67 ++++++++++++------- .../survey/EnrolleeSurveyView.test.tsx | 2 + .../survey/SurveyFullDataView.tsx | 5 +- .../survey/SurveyResponseEditor.tsx | 6 +- .../survey/SurveyResponseView.test.tsx | 3 + .../survey/SurveyResponseView.tsx | 14 +++- .../components/forms/PagedSurveyView.test.tsx | 1 + .../src/components/forms/PagedSurveyView.tsx | 3 + ui-participant/src/hub/survey/SurveyView.tsx | 1 + 10 files changed, 74 insertions(+), 32 deletions(-) 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 27a3a55d7c..f3f4bc64a7 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 @@ -138,6 +138,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()); //TODO + allAnswers.addAll(existingAnswers); + response.setAnswers(allAnswers); DataAuditInfo auditInfo = DataAuditInfo.builder() .enrolleeId(enrollee.getId()) diff --git a/ui-admin/src/study/participants/enrolleeView/EnrolleeView.tsx b/ui-admin/src/study/participants/enrolleeView/EnrolleeView.tsx index e45dcdbb1d..d364d8ccf3 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 } from 'react' import { ParticipantTask, StudyEnvironmentSurvey, SurveyResponse } from 'api/api' import { StudyEnvContextT } from 'study/StudyEnvironmentRouter' import { Link, NavLink, Route, Routes } from 'react-router-dom' @@ -18,7 +18,7 @@ import LoadingSpinner from 'util/LoadingSpinner' import CollapsableMenu from 'navbar/CollapsableMenu' import { faCircleCheck, faCircleHalfStroke } from '@fortawesome/free-solid-svg-icons' import { faCircle as faEmptyCircle, faCircleXmark } from '@fortawesome/free-regular-svg-icons' -import { Enrollee, ParticipantTaskStatus } from '@juniper/ui-core' +import { Enrollee, ParticipantTaskStatus, SurveyType } from '@juniper/ui-core' import EnrolleeOverview from './EnrolleeOverview' import { navDivStyle, navListItemStyle } from 'util/subNavStyles' @@ -45,25 +45,9 @@ export function LoadedEnrolleeView({ enrollee, studyEnvContext, onUpdate }: { enrollee: Enrollee, studyEnvContext: StudyEnvContextT, onUpdate: () => void }) { const { currentEnv, currentEnvPath } = studyEnvContext + const [responseMap, setResponseMap] = React.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') @@ -74,6 +58,38 @@ export function LoadedEnrolleeView({ enrollee, studyEnvContext, onUpdate }: { const adminSurveys = surveys .filter(survey => survey.survey.surveyType === 'ADMIN') + const updateResponseMap = (stableId: string, response: SurveyResponse) => { + console.log('updating response map', stableId, response) + 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
    @@ -111,7 +127,7 @@ export function LoadedEnrolleeView({ enrollee, studyEnvContext, onUpdate }: { return
  • {createSurveyNavLink(stableId, responseMap, survey)} - {badgeForResponses(responseMap[stableId]?.response)} + {badgeForResponses(survey.survey.surveyType, responseMap[stableId]?.response)}
  • })} }/> @@ -124,7 +140,7 @@ export function LoadedEnrolleeView({ enrollee, studyEnvContext, onUpdate }: { return
  • {createSurveyNavLink(stableId, responseMap, survey)} - {badgeForResponses(responseMap[stableId]?.response)} + {badgeForResponses(survey.survey.surveyType, responseMap[stableId]?.response)}
  • })} } @@ -141,7 +157,7 @@ export function LoadedEnrolleeView({ enrollee, studyEnvContext, onUpdate }: { return
  • {createSurveyNavLink(stableId, responseMap, survey)} - {badgeForResponses(responseMap[stableId]?.response)} + {badgeForResponses(survey.survey.surveyType, responseMap[stableId]?.response)}
  • })} } @@ -158,7 +174,7 @@ export function LoadedEnrolleeView({ enrollee, studyEnvContext, onUpdate }: { return
  • {createSurveyNavLink(stableId, responseMap, survey)} - {badgeForResponses(responseMap[stableId]?.response)} + {badgeForResponses(survey.survey.surveyType, responseMap[stableId]?.response)}
  • })} } @@ -208,6 +224,7 @@ export function LoadedEnrolleeView({ enrollee, studyEnvContext, onUpdate }: { }/> Unknown participant survey page
    }/> @@ -238,13 +255,13 @@ export function LoadedEnrolleeView({ enrollee, studyEnvContext, onUpdate }: { } /** returns an icon based on the enrollee's responses. Note this does not handle multi-responses yet */ -const badgeForResponses = (response?: SurveyResponse) => { +const badgeForResponses = (surveyType: SurveyType, response?: SurveyResponse) => { if (!response) { return statusDisplayMap['NEW'] } else { if (response.complete) { return statusDisplayMap['COMPLETE'] - } else if (response.answers.length === 0) { + } else if (surveyType === 'OUTREACH') { return statusDisplayMap['VIEWED'] } else { return statusDisplayMap['IN_PROGRESS'] diff --git a/ui-admin/src/study/participants/survey/EnrolleeSurveyView.test.tsx b/ui-admin/src/study/participants/survey/EnrolleeSurveyView.test.tsx index a69249548b..7120274a8b 100644 --- a/ui-admin/src/study/participants/survey/EnrolleeSurveyView.test.tsx +++ b/ui-admin/src/study/participants/survey/EnrolleeSurveyView.test.tsx @@ -22,6 +22,7 @@ describe('RawEnrolleeSurveyView', () => { ) render(RoutedComponent) @@ -39,6 +40,7 @@ describe('RawEnrolleeSurveyView', () => { const { RoutedComponent } = setupRouterTest( ) diff --git a/ui-admin/src/study/participants/survey/SurveyFullDataView.tsx b/ui-admin/src/study/participants/survey/SurveyFullDataView.tsx index 87fc158fb5..c3ee4d4cbd 100644 --- a/ui-admin/src/study/participants/survey/SurveyFullDataView.tsx +++ b/ui-admin/src/study/participants/survey/SurveyFullDataView.tsx @@ -24,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) diff --git a/ui-admin/src/study/participants/survey/SurveyResponseEditor.tsx b/ui-admin/src/study/participants/survey/SurveyResponseEditor.tsx index a78585bc28..c5f520f720 100644 --- a/ui-admin/src/study/participants/survey/SurveyResponseEditor.tsx +++ b/ui-admin/src/study/participants/survey/SurveyResponseEditor.tsx @@ -11,10 +11,11 @@ import { usePortalLanguage } from 'portal/usePortalLanguage' /** allows editing of a survey response */ export default function SurveyResponseEditor({ - studyEnvContext, response, survey, enrollee, adminUserId, onUpdate, setAutosaveStatus + studyEnvContext, response, survey, enrollee, adminUserId, onUpdate, setAutosaveStatus, updateResponseMap }: { studyEnvContext: StudyEnvContextT, response?: SurveyResponse, setAutosaveStatus: (status: AutosaveStatus) => void, - survey: Survey, enrollee: Enrollee, adminUserId: string, onUpdate: () => void + survey: Survey, enrollee: Enrollee, adminUserId: string, onUpdate: () => void, + updateResponseMap: (stableId: string, response: SurveyResponse) => void }) { const { defaultLanguage } = usePortalLanguage() const taskId = useTaskIdParam() @@ -42,6 +43,7 @@ export default function SurveyResponseEditor({ onUpdate() Store.addNotification(successNotification('Response saved')) }} + updateResponseMap={updateResponseMap} setAutosaveStatus={setAutosaveStatus} onFailure={() => Store.addNotification(failureNotification('Response could not be saved'))} updateProfile={() => { /*no-op for admins*/ }} diff --git a/ui-admin/src/study/participants/survey/SurveyResponseView.test.tsx b/ui-admin/src/study/participants/survey/SurveyResponseView.test.tsx index 4f6d57a361..a0e8b70713 100644 --- a/ui-admin/src/study/participants/survey/SurveyResponseView.test.tsx +++ b/ui-admin/src/study/participants/survey/SurveyResponseView.test.tsx @@ -23,6 +23,7 @@ describe('RawEnrolleeSurveyView', () => { const printSpy = jest.spyOn(window, 'print').mockImplementation(() => 1) const { RoutedComponent } = setupRouterTest( ) render(RoutedComponent) @@ -34,6 +35,7 @@ describe('RawEnrolleeSurveyView', () => { test('Viewing mode shows survey response view', async () => { const { RoutedComponent } = setupRouterTest( ) render(RoutedComponent) @@ -45,6 +47,7 @@ describe('RawEnrolleeSurveyView', () => { test('Editing mode shows the survey response editor', async () => { const { RoutedComponent } = setupRouterTest( ) render(RoutedComponent) diff --git a/ui-admin/src/study/participants/survey/SurveyResponseView.tsx b/ui-admin/src/study/participants/survey/SurveyResponseView.tsx index e37279ee1d..3eef1c29d9 100644 --- a/ui-admin/src/study/participants/survey/SurveyResponseView.tsx +++ b/ui-admin/src/study/participants/survey/SurveyResponseView.tsx @@ -27,8 +27,10 @@ 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() @@ -43,12 +45,17 @@ export default function SurveyResponseView({ enrollee, responseMap, studyEnvCont } // 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 }: { +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 { user } = useUser() @@ -119,6 +126,7 @@ export function RawEnrolleeSurveyView({ enrollee, configSurvey, response, studyE userId={enrollee.participantUserId} studyEnvContext={studyEnvContext}/>} {isEditing && user && } diff --git a/ui-core/src/components/forms/PagedSurveyView.test.tsx b/ui-core/src/components/forms/PagedSurveyView.test.tsx index 4a066a74ff..1dbd387479 100644 --- a/ui-core/src/components/forms/PagedSurveyView.test.tsx +++ b/ui-core/src/components/forms/PagedSurveyView.test.tsx @@ -247,6 +247,7 @@ 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 36ba6a8fa9..5f9874dfab 100644 --- a/ui-core/src/components/forms/PagedSurveyView.tsx +++ b/ui-core/src/components/forms/PagedSurveyView.tsx @@ -24,10 +24,12 @@ export type AutosaveStatus = 'SAVING' | 'SAVED' | 'ERROR' /** handles paging the form */ export function PagedSurveyView({ + updateResponseMap, studyEnvParams, form, response, updateEnrollee, updateProfile, taskId, selectedLanguage, 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, @@ -125,6 +127,7 @@ 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 diff --git a/ui-participant/src/hub/survey/SurveyView.tsx b/ui-participant/src/hub/survey/SurveyView.tsx index 19962bba25..25b04fde39 100644 --- a/ui-participant/src/hub/survey/SurveyView.tsx +++ b/ui-participant/src/hub/survey/SurveyView.tsx @@ -90,6 +90,7 @@ function SurveyView({ showHeaders = true }: { showHeaders?: boolean }) { studyEnvParams={studyEnvParams} form={formAndResponses.studyEnvironmentSurvey.survey} enrollee={enrollee} + updateResponseMap={() => { /* no-op */ }} proxyProfile={proxyProfile} response={formAndResponses.surveyResponse} selectedLanguage={selectedLanguage} From ad03e0e301aa2b7465b98f0a1e73b2b75f6b395b Mon Sep 17 00:00:00 2001 From: Matt Bemis Date: Wed, 12 Jun 2024 10:00:10 -0400 Subject: [PATCH 10/12] fix --- .../terra/pearl/core/service/survey/SurveyResponseService.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 747d7f3a34..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 @@ -135,7 +135,7 @@ public HubResponse updateResponse(SurveyResponse responseDto, Re List updatedAnswers = createOrUpdateAnswers(responseDto.getAnswers(), response, survey, ppUser); List allAnswers = new ArrayList<>(response.getAnswers()); - List existingAnswers = answerService.findByResponse(response.getId()); //TODO + List existingAnswers = answerService.findByResponse(response.getId()); allAnswers.addAll(existingAnswers); response.setAnswers(allAnswers); From e4bccfb45f2021b72f2a6828680ff0a6623390c5 Mon Sep 17 00:00:00 2001 From: Matt Bemis Date: Wed, 12 Jun 2024 10:21:59 -0400 Subject: [PATCH 11/12] fix --- .../pearl/core/model/workflow/TaskType.java | 2 +- .../service/survey/SurveyTaskDispatcher.java | 3 +-- .../participantList/ParticipantList.tsx | 2 +- .../src/study/surveys/FormOptionsModal.tsx | 23 ++++++++++++------- 4 files changed, 18 insertions(+), 12 deletions(-) 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 997d5d6037..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 @@ -5,5 +5,5 @@ public enum TaskType { SURVEY, // a research survey OUTREACH, // an outreach activity -- not essential for research KIT_REQUEST, - ADMIN // a task for study staff to complete -- not visible to participants + 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/SurveyTaskDispatcher.java b/core/src/main/java/bio/terra/pearl/core/service/survey/SurveyTaskDispatcher.java index 9e9ee671a0..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; @@ -249,7 +248,7 @@ public ParticipantTask buildTask(Enrollee enrollee, PortalParticipantUser portal SurveyType.CONSENT, TaskType.CONSENT, SurveyType.RESEARCH, TaskType.SURVEY, SurveyType.OUTREACH, TaskType.OUTREACH, - SurveyType.ADMIN, TaskType.ADMIN + SurveyType.ADMIN, TaskType.ADMIN_FORM ); /** diff --git a/ui-admin/src/study/participants/participantList/ParticipantList.tsx b/ui-admin/src/study/participants/participantList/ParticipantList.tsx index d0073c7444..17beb1d898 100644 --- a/ui-admin/src/study/participants/participantList/ParticipantList.tsx +++ b/ui-admin/src/study/participants/participantList/ParticipantList.tsx @@ -88,7 +88,7 @@ function ParticipantList({ studyEnvContext }: {studyEnvContext: StudyEnvContextT }, { id: 'lastLogin', header: 'Last login', - accessorFn: row => row.participantUser?.lastLogin, + accessorKey: 'participantUser.lastLogin', enableColumnFilter: false, meta: { columnType: 'instant' diff --git a/ui-admin/src/study/surveys/FormOptionsModal.tsx b/ui-admin/src/study/surveys/FormOptionsModal.tsx index 7f58c2abd4..1fa1eb8cfc 100644 --- a/ui-admin/src/study/surveys/FormOptionsModal.tsx +++ b/ui-admin/src/study/surveys/FormOptionsModal.tsx @@ -113,15 +113,22 @@ export const FormOptions = ({ studyEnvContext, initialWorkingForm, updateWorking })} /> Allow study staff to edit participant responses } -
    Eligibility Rule
    + Eligibility Rule +

    + Use a + search expression + to conditionally assign the survey. +

    {userHasPermission(user, studyEnvContext.portal.id, 'prototype_tester') - &&
    updateWorkingForm({ - ...workingForm, eligibilityRule: exp - })}/>
    } - - + updateWorkingForm({ + ...workingForm, eligibilityRule: exp + })} + searchExpression={(workingForm as Survey).eligibilityRule || ''}/> +
    } + { updateWorkingForm({ ...workingForm, eligibilityRule: e.target.value From fb7d6a161a3ab876d800eb8e862b50d98f575b6d Mon Sep 17 00:00:00 2001 From: Matt Bemis Date: Wed, 12 Jun 2024 10:56:44 -0400 Subject: [PATCH 12/12] fix badge logic --- .../enrolleeView/EnrolleeView.tsx | 19 +++++++++---------- ui-core/src/types/task.ts | 1 + 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/ui-admin/src/study/participants/enrolleeView/EnrolleeView.tsx b/ui-admin/src/study/participants/enrolleeView/EnrolleeView.tsx index d364d8ccf3..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, { useEffect } 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' @@ -18,7 +18,7 @@ import LoadingSpinner from 'util/LoadingSpinner' import CollapsableMenu from 'navbar/CollapsableMenu' import { faCircleCheck, faCircleHalfStroke } from '@fortawesome/free-solid-svg-icons' import { faCircle as faEmptyCircle, faCircleXmark } from '@fortawesome/free-regular-svg-icons' -import { Enrollee, ParticipantTaskStatus, SurveyType } from '@juniper/ui-core' +import { Enrollee, ParticipantTaskStatus } from '@juniper/ui-core' import EnrolleeOverview from './EnrolleeOverview' import { navDivStyle, navListItemStyle } from 'util/subNavStyles' @@ -45,7 +45,7 @@ export function LoadedEnrolleeView({ enrollee, studyEnvContext, onUpdate }: { enrollee: Enrollee, studyEnvContext: StudyEnvContextT, onUpdate: () => void }) { const { currentEnv, currentEnvPath } = studyEnvContext - const [responseMap, setResponseMap] = React.useState({}) + const [responseMap, setResponseMap] = useState({}) const surveys: StudyEnvironmentSurvey[] = currentEnv.configuredSurveys @@ -59,7 +59,6 @@ export function LoadedEnrolleeView({ enrollee, studyEnvContext, onUpdate }: { .filter(survey => survey.survey.surveyType === 'ADMIN') const updateResponseMap = (stableId: string, response: SurveyResponse) => { - console.log('updating response map', stableId, response) setResponseMap({ ...responseMap, [stableId]: { @@ -127,7 +126,7 @@ export function LoadedEnrolleeView({ enrollee, studyEnvContext, onUpdate }: { return
  • {createSurveyNavLink(stableId, responseMap, survey)} - {badgeForResponses(survey.survey.surveyType, responseMap[stableId]?.response)} + {badgeForResponses(responseMap[stableId]?.response)}
  • })} }/> @@ -140,7 +139,7 @@ export function LoadedEnrolleeView({ enrollee, studyEnvContext, onUpdate }: { return
  • {createSurveyNavLink(stableId, responseMap, survey)} - {badgeForResponses(survey.survey.surveyType, responseMap[stableId]?.response)} + {badgeForResponses(responseMap[stableId]?.response)}
  • })} } @@ -157,7 +156,7 @@ export function LoadedEnrolleeView({ enrollee, studyEnvContext, onUpdate }: { return
  • {createSurveyNavLink(stableId, responseMap, survey)} - {badgeForResponses(survey.survey.surveyType, responseMap[stableId]?.response)} + {badgeForResponses(responseMap[stableId]?.response)}
  • })} } @@ -174,7 +173,7 @@ export function LoadedEnrolleeView({ enrollee, studyEnvContext, onUpdate }: { return
  • {createSurveyNavLink(stableId, responseMap, survey)} - {badgeForResponses(survey.survey.surveyType, responseMap[stableId]?.response)} + {badgeForResponses(responseMap[stableId]?.response)}
  • })} } @@ -255,13 +254,13 @@ export function LoadedEnrolleeView({ enrollee, studyEnvContext, onUpdate }: { } /** returns an icon based on the enrollee's responses. Note this does not handle multi-responses yet */ -const badgeForResponses = (surveyType: SurveyType, response?: SurveyResponse) => { +const badgeForResponses = (response?: SurveyResponse) => { if (!response) { return statusDisplayMap['NEW'] } else { if (response.complete) { return statusDisplayMap['COMPLETE'] - } else if (surveyType === 'OUTREACH') { + } else if (response.answers.length === 0) { return statusDisplayMap['VIEWED'] } else { return statusDisplayMap['IN_PROGRESS'] 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 {}