diff --git a/src/analysis/individualStudy/stats/TrialVisualization.tsx b/src/analysis/individualStudy/stats/TrialVisualization.tsx index 50431c60ba..bd4eeb30e4 100644 --- a/src/analysis/individualStudy/stats/TrialVisualization.tsx +++ b/src/analysis/individualStudy/stats/TrialVisualization.tsx @@ -4,7 +4,7 @@ import { import { useMemo } from 'react'; import { ParticipantData } from '../../../storage/types'; import { StudyConfig } from '../../../parser/types'; -import { studyComponentToIndividualComponent } from '../../../utils/handleComponentInheritance'; +import { getComponentName, studyComponentToIndividualComponent } from '../../../utils/handleComponentInheritance'; import { ResponseVisualization } from './ResponseVisualization'; export function TrialVisualization({ @@ -12,7 +12,8 @@ export function TrialVisualization({ }: { participantData: ParticipantData[]; studyConfig: StudyConfig, trialId?: string; }) { - const trialConfig = trialId && trialId !== 'end' && studyComponentToIndividualComponent(studyConfig.components[trialId], studyConfig); + const componentName = getComponentName(trialId || ''); + const trialConfig = trialId && trialId !== 'end' && studyComponentToIndividualComponent(studyConfig.components[componentName], studyConfig); const items = useMemo(() => [ { id: 'Config and Timing', type: 'metadata' } as { id: 'Config and Timing', type: 'metadata'}, diff --git a/src/analysis/individualStudy/summary/utils.tsx b/src/analysis/individualStudy/summary/utils.tsx index d6b0789e96..240621f76f 100644 --- a/src/analysis/individualStudy/summary/utils.tsx +++ b/src/analysis/individualStudy/summary/utils.tsx @@ -7,20 +7,23 @@ import { Response, StudyConfig } from '../../../parser/types'; import { componentAnswersAreCorrect } from '../../../utils/correctAnswer'; import { studyComponentToIndividualComponent } from '../../../utils/handleComponentInheritance'; -function filterParticipants(visibleParticipants: ParticipantData[], componentName?: string, excludeRejected?: boolean): ParticipantData[] { +function filterParticipants(visibleParticipants: ParticipantData[], identifier?: string, excludeRejected?: boolean): ParticipantData[] { return visibleParticipants.filter((participant) => { // Filter out rejected participants if excludeRejected is true const isNotRejected = !excludeRejected || !participant.rejected; // Filter by component - participant must have an answer for the component and has finished it - const hasValidComponentAnswer = !componentName || Object.values(participant.answers).some((answer) => answer.componentName === componentName && answer.startTime > 0 && answer.endTime !== -1); + const hasValidComponentAnswer = !identifier || Object.values(participant.answers).some((answer) => { + const cleanAnswerIdentifier = answer.identifier.replace(/_[^_]*$/, ''); + return cleanAnswerIdentifier === identifier && answer.startTime > 0 && answer.endTime !== -1; + }); return isNotRejected && hasValidComponentAnswer; }); } -function calculateParticipantCounts(visibleParticipants: ParticipantData[], componentName?: string): ParticipantCounts { - const filteredParticipants = filterParticipants(visibleParticipants, componentName, false); +function calculateParticipantCounts(visibleParticipants: ParticipantData[], identifier?: string): ParticipantCounts { + const filteredParticipants = filterParticipants(visibleParticipants, identifier, false); const participantCounts: ParticipantCounts = { total: filteredParticipants.length, @@ -33,9 +36,9 @@ function calculateParticipantCounts(visibleParticipants: ParticipantData[], comp return participantCounts; } -function calculateDateStats(visibleParticipants: ParticipantData[], componentName?: string): { startDate: Date | null; endDate: Date | null } { +function calculateDateStats(visibleParticipants: ParticipantData[], identifier?: string): { startDate: Date | null; endDate: Date | null } { // Filter out rejected participants and filter by component if provided - const filteredParticipants = filterParticipants(visibleParticipants, componentName, true); + const filteredParticipants = filterParticipants(visibleParticipants, identifier, true); const answers = filteredParticipants .flatMap((participant) => Object.values(participant.answers)) .filter((answer) => answer.endTime !== -1); @@ -53,15 +56,16 @@ function calculateDateStats(visibleParticipants: ParticipantData[], componentNam }; } -function calculateTimeStats(visibleParticipants: ParticipantData[], componentName?: string): { avgTime: number; avgCleanTime: number; participantsWithInvalidCleanTimeCount: number } { +function calculateTimeStats(visibleParticipants: ParticipantData[], identifier?: string): { avgTime: number; avgCleanTime: number; participantsWithInvalidCleanTimeCount: number } { // Filter out rejected participants and filter by component if provided - const filteredParticipants = filterParticipants(visibleParticipants, componentName, true); + const filteredParticipants = filterParticipants(visibleParticipants, identifier, true); let participantsWithInvalidCleanTimeCount = 0; + const time = filteredParticipants.reduce((acc, participant) => { let hasInvalidCleanTime = false; const timeStats = Object.values(participant.answers) - .filter((answer) => (!componentName || answer.componentName === componentName) && answer.endTime !== -1) + .filter((answer) => (!identifier || answer.identifier.replace(/_[^_]*$/, '') === identifier) && answer.endTime !== -1) .map((answer) => { const cleanedDuration = getCleanedDuration(answer as never); if (cleanedDuration === -1) { diff --git a/src/analysis/individualStudy/thinkAloud/ThinkAloudFooter.tsx b/src/analysis/individualStudy/thinkAloud/ThinkAloudFooter.tsx index 14c91d2143..44af607105 100644 --- a/src/analysis/individualStudy/thinkAloud/ThinkAloudFooter.tsx +++ b/src/analysis/individualStudy/thinkAloud/ThinkAloudFooter.tsx @@ -160,7 +160,7 @@ export function ThinkAloudFooter({ setSearchParams({ participantId, currentTrial: Object.entries(participant.answers).find(([_, ans]) => +ans.trialOrder.split('_')[0] === 0)?.[0] || '' }); } - return participant?.answers[currentTrial]?.componentName ?? ''; + return participant?.answers[currentTrial]?.identifier.replace(/_[^_]*$/, '') ?? ''; }, [currentTrial, participant, participantId, setSearchParams]); const xScale = useMemo(() => { @@ -416,7 +416,7 @@ export function ThinkAloudFooter({ // this needs to be in a helper or two which we dont currently have onChange={(e: string | null) => { if (participant && e) { - const trial = Object.entries(participant.answers).find(([_key, ans]) => +ans.trialOrder.split('_')[0] === getSequenceFlatMap(participant?.sequence).indexOf(e))?.[0] || ''; + const trial = Object.entries(participant.answers).find(([_key, ans]) => +ans.trialOrder.split('_')[0] === getSequenceFlatMap(participant?.sequence, '').indexOf(e))?.[0] || ''; localStorage.setItem('currentTrial', trial); setSearchParams({ participantId, currentTrial: trial }); diff --git a/src/components/StepRenderer.tsx b/src/components/StepRenderer.tsx index e9ec6f23b7..a5db3cbc8f 100644 --- a/src/components/StepRenderer.tsx +++ b/src/components/StepRenderer.tsx @@ -17,7 +17,7 @@ import { WindowEventsContext } from '../store/hooks/useWindowEvents'; import { useStoreSelector, useStoreDispatch, useStoreActions } from '../store/store'; import { AnalysisFooter } from './interface/AnalysisFooter'; import { useIsAnalysis } from '../store/hooks/useIsAnalysis'; -import { studyComponentToIndividualComponent } from '../utils/handleComponentInheritance'; +import { getComponentName, studyComponentToIndividualComponent } from '../utils/handleComponentInheritance'; import { useCurrentComponent } from '../routes/utils'; import { ResolutionWarning } from './interface/ResolutionWarning'; import { useFetchStylesheet } from '../utils/fetchStylesheet'; @@ -35,7 +35,7 @@ export function StepRenderer() { const studyConfig = useStudyConfig(); const currentComponent = useCurrentComponent(); - const componentConfig = useMemo(() => studyComponentToIndividualComponent(studyConfig.components[currentComponent] || {}, studyConfig), [currentComponent, studyConfig]); + const componentConfig = useMemo(() => studyComponentToIndividualComponent(studyConfig.components[getComponentName(currentComponent)] || {}, studyConfig), [currentComponent, studyConfig]); const windowEventDebounceTime = useMemo(() => componentConfig.windowEventDebounceTime ?? studyConfig.uiConfig.windowEventDebounceTime ?? 100, [componentConfig, studyConfig]); @@ -130,7 +130,7 @@ export function StepRenderer() { const { developmentModeEnabled, dataCollectionEnabled } = useMemo(() => modes, [modes]); // No default value for withSidebar since it's a required field in uiConfig - const sidebarOpen = useMemo(() => (((analysisHasScreenRecording && analysisCanPlayScreenRecording) || currentComponent === 'end') ? false : (componentConfig.withSidebar ?? studyConfig.uiConfig.withSidebar)), [analysisHasScreenRecording, analysisCanPlayScreenRecording, currentComponent, componentConfig.withSidebar, studyConfig.uiConfig.withSidebar]); + const sidebarOpen = useMemo(() => (((analysisHasScreenRecording && analysisCanPlayScreenRecording) || getComponentName(currentComponent) === 'end') ? false : (componentConfig.withSidebar ?? studyConfig.uiConfig.withSidebar)), [analysisHasScreenRecording, analysisCanPlayScreenRecording, currentComponent, componentConfig.withSidebar, studyConfig.uiConfig.withSidebar]); const sidebarWidth = useMemo(() => componentConfig?.sidebarWidth ?? studyConfig.uiConfig.sidebarWidth ?? 300, [componentConfig, studyConfig]); const showTitleBar = useMemo(() => componentConfig.showTitleBar ?? studyConfig.uiConfig.showTitleBar ?? true, [componentConfig, studyConfig]); diff --git a/src/components/interface/AppHeader.tsx b/src/components/interface/AppHeader.tsx index e5dce0a869..7e3a4e16e0 100644 --- a/src/components/interface/AppHeader.tsx +++ b/src/components/interface/AppHeader.tsx @@ -36,7 +36,7 @@ import { calculateProgressData } from '../../storage/engines/utils'; import { PREFIX } from '../../utils/Prefix'; import { getNewParticipant } from '../../utils/nextParticipant'; import { RecordingAudioWaveform } from './RecordingAudioWaveform'; -import { studyComponentToIndividualComponent } from '../../utils/handleComponentInheritance'; +import { getComponentName, studyComponentToIndividualComponent } from '../../utils/handleComponentInheritance'; import { useRecordingContext } from '../../store/hooks/useRecording'; export function AppHeader({ developmentModeEnabled, dataCollectionEnabled }: { developmentModeEnabled: boolean; dataCollectionEnabled: boolean }) { @@ -50,7 +50,7 @@ export function AppHeader({ developmentModeEnabled, dataCollectionEnabled }: { d const { storageEngine } = useStorageEngine(); const currentComponent = useCurrentComponent(); - const componentConfig = useMemo(() => studyComponentToIndividualComponent(studyConfig.components[currentComponent] || {}, studyConfig), [currentComponent, studyConfig]); + const componentConfig = useMemo(() => studyComponentToIndividualComponent(studyConfig.components[getComponentName(currentComponent)] || {}, studyConfig), [currentComponent, studyConfig]); const currentStep = useCurrentStep(); diff --git a/src/components/interface/AppNavBar.tsx b/src/components/interface/AppNavBar.tsx index fc873fd5dc..bb33759031 100644 --- a/src/components/interface/AppNavBar.tsx +++ b/src/components/interface/AppNavBar.tsx @@ -5,13 +5,13 @@ import { useStudyConfig } from '../../store/hooks/useStudyConfig'; import { useStoredAnswer } from '../../store/hooks/useStoredAnswer'; import { ResponseBlock } from '../response/ResponseBlock'; import { useCurrentComponent } from '../../routes/utils'; -import { studyComponentToIndividualComponent } from '../../utils/handleComponentInheritance'; +import { getComponentName, studyComponentToIndividualComponent } from '../../utils/handleComponentInheritance'; export function AppNavBar({ width, top, sidebarOpen }: { width: number, top: number, sidebarOpen: boolean }) { // Get the config for the current step const studyConfig = useStudyConfig(); const currentComponent = useCurrentComponent(); - const stepConfig = studyConfig.components[currentComponent]; + const stepConfig = studyConfig.components[getComponentName(currentComponent)]; const currentConfig = useMemo(() => { if (stepConfig) { diff --git a/src/components/interface/StepsPanel.tsx b/src/components/interface/StepsPanel.tsx index b91f197434..a249dbe2ec 100644 --- a/src/components/interface/StepsPanel.tsx +++ b/src/components/interface/StepsPanel.tsx @@ -21,11 +21,12 @@ import { import { useNavigate, useLocation } from 'react-router'; import { ParticipantData, Response, StudyConfig } from '../../parser/types'; import { Sequence, StoredAnswer } from '../../store/types'; -import { addPathToComponentBlock } from '../../utils/getSequenceFlatMap'; +import { addPathToComponentBlock, getSequenceFlatMap } from '../../utils/getSequenceFlatMap'; import { useStudyId } from '../../routes/utils'; import { encryptIndex } from '../../utils/encryptDecryptIndex'; import { isDynamicBlock } from '../../parser/utils'; import { componentAnswersAreCorrect } from '../../utils/correctAnswer'; +import { getComponentName } from '../../utils/handleComponentInheritance'; function hasRandomization(responses: Response[]) { return responses.some((response) => { @@ -98,6 +99,7 @@ type ComponentStepItem = StepItemBase & { isLibraryImport: boolean; importedLibraryName?: string; component?: StudyConfig['components'][string]; + sequenceName: string; componentAnswer?: StoredAnswer; componentName: string; // Full component name (e.g., package.components.ComponentName) isExcluded?: boolean; // Component was excluded from participant sequence @@ -257,13 +259,15 @@ export function StepsPanel({ let newFlatTree: StepItem[] = []; if (participantSequence === undefined) { // Browse Components - newFlatTree = Object.keys(studyConfig.components).map((key) => { + newFlatTree = getSequenceFlatMap(studyConfig.sequence).map((key) => { const { label, isLibraryImport, importedLibraryName, } = parseLibraryComponentReference(key); + const componentName = getComponentName(key); + return { type: 'component', label, @@ -272,7 +276,8 @@ export function StepsPanel({ href: `/${studyId}/reviewer-${key}`, isLibraryImport, importedLibraryName, - componentName: key, + componentName, + sequenceName: key, }; }); } else { @@ -313,6 +318,7 @@ export function StepsPanel({ component: studyConfig.components[node], componentAnswer: participantAnswers[componentIdentifier], componentName: node, + sequenceName: node, }); if (dynamic) { @@ -417,6 +423,7 @@ export function StepsPanel({ component: studyConfig.components[excludedComponent], componentName: excludedComponent, isExcluded: true, + sequenceName: excludedComponent, }); }); @@ -472,6 +479,7 @@ export function StepsPanel({ component: studyConfig.components[child], componentName: child, isExcluded: true, + sequenceName: child, }); } else { const childBlockPath = `${excludedParentPath}.${child.id ?? child.order}_excluded`; @@ -544,7 +552,7 @@ export function StepsPanel({ // Set full and rendered flat tree setFullFlatTree(newFlatTree); setRenderedFlatTree(newFlatTree); - }, [fullOrder, participantAnswers, participantSequence, studyConfig.components, studyId]); + }, [fullOrder, participantAnswers, participantSequence, studyConfig, studyConfig.components, studyId]); const collapseBlock = useCallback((startIndex: number, startItem: StepItem) => { setRenderedFlatTree((prevRenderedFlatTree) => { @@ -680,6 +688,7 @@ export function StepsPanel({ component, componentAnswer, componentName, + sequenceName, isLibraryImport: isComponentLibraryImport, importedLibraryName: componentImportedLibraryName, } = (comp ?? {}) as Partial; @@ -740,7 +749,7 @@ export function StepsPanel({ onClick={() => { if (isComponent && href && !isExcluded) { if (isAnalysis) { - navigate(`/analysis/stats/${studyId}/stats/${encodeURIComponent(String(componentName))}`); + navigate(`/analysis/stats/${studyId}/stats/${encodeURIComponent(String(sequenceName))}`); } else { navigate(`${href}${location.search}`); } diff --git a/src/controllers/ComponentController.tsx b/src/controllers/ComponentController.tsx index 67a1c38245..52b0187956 100644 --- a/src/controllers/ComponentController.tsx +++ b/src/controllers/ComponentController.tsx @@ -31,7 +31,7 @@ import { findBlockForStep } from '../utils/getSequenceFlatMap'; import { VegaController, VegaProvState } from './VegaController'; import { useIsAnalysis } from '../store/hooks/useIsAnalysis'; import { VideoController } from './VideoController'; -import { studyComponentToIndividualComponent } from '../utils/handleComponentInheritance'; +import { getComponentName, studyComponentToIndividualComponent } from '../utils/handleComponentInheritance'; import { useFetchStylesheet } from '../utils/fetchStylesheet'; import { ScreenRecordingReplay } from '../components/screenRecording/ScreenRecordingReplay'; import { decryptIndex, encryptIndex } from '../utils/encryptDecryptIndex'; @@ -45,7 +45,7 @@ export function ComponentController() { const currentComponent = useCurrentComponent(); const studyId = useStudyId(); - const stepConfig = studyConfig.components[currentComponent]; + const stepConfig = studyConfig.components[getComponentName(currentComponent)]; const { storageEngine } = useStorageEngine(); const answers = useStoreSelector((store) => store.answers); diff --git a/src/routes/utils.tsx b/src/routes/utils.tsx index bc75e6f995..e37745feb7 100644 --- a/src/routes/utils.tsx +++ b/src/routes/utils.tsx @@ -10,7 +10,7 @@ import { decryptIndex, encryptIndex } from '../utils/encryptDecryptIndex'; import { JumpFunctionParameters, JumpFunctionReturnVal } from '../store/types'; import { findFuncBlock } from '../utils/getSequenceFlatMap'; import { useStudyConfig } from '../store/hooks/useStudyConfig'; -import { getComponent } from '../utils/handleComponentInheritance'; +import { getComponent, getComponentName } from '../utils/handleComponentInheritance'; export function useStudyId(): string { const { studyId } = useParams(); @@ -54,7 +54,7 @@ export function useCurrentComponent(): string { const [indexWhenSettingComponentName, setIndexWhenSettingComponentName] = useState(null); - const currentComponent = useMemo(() => (typeof currentStep === 'number' ? getComponent(flatSequence[currentStep], studyConfig) : currentStep.includes('reviewer-') || currentStep.startsWith('__') ? currentStep : null), [currentStep, flatSequence, studyConfig]); + const currentComponent = useMemo(() => (typeof currentStep === 'number' ? getComponent(getComponentName(flatSequence[currentStep]), studyConfig) : currentStep.includes('reviewer-') || currentStep.startsWith('__') ? currentStep : null), [currentStep, flatSequence, studyConfig]); const [compName, setCompName] = useState('__dynamicLoading'); @@ -82,7 +82,7 @@ export function useCurrentComponent(): string { useEffect(() => { if (typeof currentStep === 'number') { - const component = getComponent(flatSequence[currentStep], studyConfig); + const component = getComponent(getComponentName(flatSequence[currentStep]), studyConfig); const funcName = flatSequence[currentStep]; const decryptedFuncIndex = funcIndex ? decryptIndex(funcIndex) : 0; diff --git a/src/store/hooks/useNextStep.ts b/src/store/hooks/useNextStep.ts index 16439cddfc..849ca2f4a7 100644 --- a/src/store/hooks/useNextStep.ts +++ b/src/store/hooks/useNextStep.ts @@ -23,9 +23,11 @@ import { import { decryptIndex, encryptIndex } from '../../utils/encryptDecryptIndex'; import { useIsAnalysis } from './useIsAnalysis'; import { componentAnswersAreCorrect } from '../../utils/correctAnswer'; +import { getComponentName } from '../../utils/handleComponentInheritance'; function checkAllAnswersCorrect(answers: StoredAnswer['answer'], componentId: string, componentConfig: IndividualComponent | InheritedComponent, studyConfig: StudyConfig) { - const componentName = componentId.slice(0, componentId.lastIndexOf('_')); + const component = componentId.slice(0, componentId.lastIndexOf('_')); + const componentName = getComponentName(component); // Find the matching component in the study config const foundConfigComponent = Object.entries(studyConfig.components).find(([configComponentId]) => configComponentId === componentName); diff --git a/src/store/store.tsx b/src/store/store.tsx index 77101e6aea..6051dfe19e 100644 --- a/src/store/store.tsx +++ b/src/store/store.tsx @@ -11,7 +11,7 @@ import { } from './types'; import { getSequenceFlatMap } from '../utils/getSequenceFlatMap'; import { REVISIT_MODE } from '../storage/engines/types'; -import { studyComponentToIndividualComponent } from '../utils/handleComponentInheritance'; +import { getComponentName, studyComponentToIndividualComponent } from '../utils/handleComponentInheritance'; import { randomizeOptions, randomizeQuestionOrder, randomizeForm } from '../utils/handleResponseRandomization'; export async function studyStoreCreator( @@ -29,10 +29,10 @@ export async function studyStoreCreator( const emptyAnswers: ParticipantData['answers'] = Object.fromEntries(flatSequence.filter((id) => id !== 'end') .map((id, idx) => { - const componentConfig = studyComponentToIndividualComponent(config.components[id] || {}, config); - + const componentName = getComponentName(id); + const componentConfig = studyComponentToIndividualComponent(config.components[componentName] || {}, config); // Make sure we dont include dynamic blocks as empty answers - if (!config.components[id]) { + if (!config.components[componentName]) { return null; } @@ -42,7 +42,7 @@ export async function studyStoreCreator( answer: {}, identifier: `${id}_${idx}`, trialOrder: `${idx}`, - componentName: id, + componentName, incorrectAnswers: {}, startTime: 0, endTime: -1, diff --git a/src/utils/getSequenceFlatMap.ts b/src/utils/getSequenceFlatMap.ts index ff255f99e9..ffe2571558 100644 --- a/src/utils/getSequenceFlatMap.ts +++ b/src/utils/getSequenceFlatMap.ts @@ -2,8 +2,10 @@ import { DynamicBlock, StudyConfig } from '../parser/types'; import { isDynamicBlock } from '../parser/utils'; import { Sequence } from '../store/types'; -export function getSequenceFlatMap(sequence: T): string[] { - return isDynamicBlock(sequence) ? [sequence.id] : sequence.components.flatMap((component) => (typeof component === 'string' ? component : getSequenceFlatMap(component))); +export function getSequenceFlatMap(sequence: T, block:string = ''): string[] { + return isDynamicBlock(sequence) + ? [`${block ? `${block}:` : ''}${sequence.id}`] + : sequence.components.flatMap((component) => (typeof component === 'string' ? (`${sequence.id ? `${sequence.id}:` : ''}${component}`) : getSequenceFlatMap(component, `${block}:${sequence.id || ''}`))); } function findAllFuncBlocks(sequence: StudyConfig['sequence']): DynamicBlock[] { diff --git a/src/utils/handleComponentInheritance.ts b/src/utils/handleComponentInheritance.ts index dc65c6658a..4025e25b50 100644 --- a/src/utils/handleComponentInheritance.ts +++ b/src/utils/handleComponentInheritance.ts @@ -19,3 +19,8 @@ export function getComponent(name: string, studyConfig: StudyConfig): Individual } return studyComponentToIndividualComponent(studyConfig.components[name], studyConfig); } + +export function getComponentName(sequenceName: string) { + const m = sequenceName.split(':'); + return m[m.length - 1]; +}