From 1524204182c76b944dd3184bceff7ae5975d3f3a Mon Sep 17 00:00:00 2001 From: Chris Lei Date: Tue, 28 Jul 2026 13:58:23 -0700 Subject: [PATCH 1/4] Include answerId in responses URL query params --- .../Notification/Notification.test.tsx | 40 ++----------------- .../Notification/Notification.tsx | 14 ++++--- 2 files changed, 13 insertions(+), 41 deletions(-) diff --git a/src/shared/layouts/BaseLayout/components/TopBar/Notifications/Notification/Notification.test.tsx b/src/shared/layouts/BaseLayout/components/TopBar/Notifications/Notification/Notification.test.tsx index 146c96045c..72eb0a35a1 100644 --- a/src/shared/layouts/BaseLayout/components/TopBar/Notifications/Notification/Notification.test.tsx +++ b/src/shared/layouts/BaseLayout/components/TopBar/Notifications/Notification/Notification.test.tsx @@ -6,7 +6,6 @@ import { vi } from 'vitest'; import { mockedAlert, mockedFullSubjectId1 } from 'shared/mock'; import { renderWithProviders } from 'shared/utils/renderWithProviders'; -import * as useEncryptionStorageFunc from 'shared/hooks/useEncryptionStorage'; import { Notification } from './Notification'; @@ -82,12 +81,7 @@ describe('Notification', () => { fireEvent.click(screen.getByTestId(`notification-${mockedAlert.id}`)); expect(mockedSetCurrentId).toBeCalledWith(''); }); - test('should navigate when click on response data button without existed encryption', async () => { - const mockedgGetAppletPrivateKey = vi.fn().mockReturnValue(''); - vi.spyOn(useEncryptionStorageFunc, 'useEncryptionStorage').mockReturnValue({ - getAppletPrivateKey: mockedgGetAppletPrivateKey, - }); - + test('should navigate to the answer associated with the alert when click on response data button', async () => { renderWithProviders( { expect(button).toBeInTheDocument(); await userEvent.click(button); - expect(mockedUseNavigate).toBeCalledWith( - `/dashboard/${mockedAlert.appletId}/participants/${mockedFullSubjectId1}/activities/${mockedAlert.activityId}/responses`, - ); - }); - - test('should navigate when click on response data button with existed encryption', async () => { - const mockedgGetAppletPrivateKey = vi.fn().mockReturnValue('123'); - vi.spyOn(useEncryptionStorageFunc, 'useEncryptionStorage').mockReturnValue({ - getAppletPrivateKey: mockedgGetAppletPrivateKey, + expect(mockedUseNavigate).toBeCalledWith({ + pathname: `/dashboard/${mockedAlert.appletId}/participants/${mockedFullSubjectId1}/activities/${mockedAlert.activityId}/responses`, + search: `selectedDate=2023-08-03&answerId=${mockedAlert.answerId}`, }); - - renderWithProviders( - , - ); - - const button = screen.getByRole('button', { - name: /takeMeToTheResponseData/i, - }); - expect(button).toBeInTheDocument(); - await userEvent.click(button); - - expect(mockedUseNavigate).toBeCalledWith( - `/dashboard/${mockedAlert.appletId}/participants/${mockedFullSubjectId1}/activities/${mockedAlert.activityId}/responses`, - ); }); }); diff --git a/src/shared/layouts/BaseLayout/components/TopBar/Notifications/Notification/Notification.tsx b/src/shared/layouts/BaseLayout/components/TopBar/Notifications/Notification/Notification.tsx index b65d48aa79..6143d4e763 100644 --- a/src/shared/layouts/BaseLayout/components/TopBar/Notifications/Notification/Notification.tsx +++ b/src/shared/layouts/BaseLayout/components/TopBar/Notifications/Notification/Notification.tsx @@ -1,6 +1,6 @@ import { Box } from '@mui/material'; import { useTranslation } from 'react-i18next'; -import { generatePath, useNavigate } from 'react-router-dom'; +import { createSearchParams, generatePath, useNavigate } from 'react-router-dom'; import { useAppDispatch } from 'redux/store'; import { page } from 'resources'; @@ -44,6 +44,7 @@ export const Notification = ({ type, activityId, answerId, + createdAt, }: NotificationProps) => { const { t } = useTranslation('app'); const dispatch = useAppDispatch(); @@ -74,14 +75,17 @@ export const Notification = ({ }; const navigateToResponseData = () => { - navigate( - generatePath(page.appletParticipantActivityDetailsDataReview, { + navigate({ + pathname: generatePath(page.appletParticipantActivityDetailsDataReview, { appletId, subjectId, activityId, - answerId, }), - ); + search: createSearchParams({ + selectedDate: createdAt.substring(0, 10), // ISO 8601 date in UTC + ...(answerId && { answerId }), + }).toString(), + }); }; return ( From 482ed633466c82f4bf10632cb0631c9cb7b5488e Mon Sep 17 00:00:00 2001 From: Chris Lei Date: Tue, 28 Jul 2026 14:34:27 -0700 Subject: [PATCH 2/4] Reselect answer when route answerId changes The previous guard skipped selection whenever an answer was already selected. The updated guard reselects when answerId/submitId changes. --- .../ReviewMenuItem/ReviewMenuItem.test.tsx | 12 ++++++++++++ .../ReviewMenu/ReviewMenuItem/ReviewMenuItem.tsx | 8 ++++++-- 2 files changed, 18 insertions(+), 2 deletions(-) diff --git a/src/modules/Dashboard/features/RespondentData/RespondentDataReview/ReviewMenu/ReviewMenuItem/ReviewMenuItem.test.tsx b/src/modules/Dashboard/features/RespondentData/RespondentDataReview/ReviewMenu/ReviewMenuItem/ReviewMenuItem.test.tsx index 00266db6e8..f23a0a4f7a 100644 --- a/src/modules/Dashboard/features/RespondentData/RespondentDataReview/ReviewMenu/ReviewMenuItem/ReviewMenuItem.test.tsx +++ b/src/modules/Dashboard/features/RespondentData/RespondentDataReview/ReviewMenu/ReviewMenuItem/ReviewMenuItem.test.tsx @@ -108,6 +108,18 @@ describe('Review Menu Item component', () => { }); }); + test('renders and functions correctly when an answer ID is present in the route and an answer is already selected', async () => { + renderComponent(routeWithAnswerId, { isSelected: true, selectedAnswer: latestAnswer }); + + await waitFor(() => { + expect(mockedOnSelectAnswer).toHaveBeenCalledWith({ + answer: preselectedAnswer, + isRouteCreated: true, + }); + }); + expect(mockedOnSelectItem).toHaveBeenCalledWith(mockedActivity); + }); + test('renders with already selected activity', async () => { renderComponent(routeWithoutAnswerId, { isSelected: true }); diff --git a/src/modules/Dashboard/features/RespondentData/RespondentDataReview/ReviewMenu/ReviewMenuItem/ReviewMenuItem.tsx b/src/modules/Dashboard/features/RespondentData/RespondentDataReview/ReviewMenu/ReviewMenuItem/ReviewMenuItem.tsx index a27c59ab68..62b51f14df 100644 --- a/src/modules/Dashboard/features/RespondentData/RespondentDataReview/ReviewMenu/ReviewMenuItem/ReviewMenuItem.tsx +++ b/src/modules/Dashboard/features/RespondentData/RespondentDataReview/ReviewMenu/ReviewMenuItem/ReviewMenuItem.tsx @@ -47,13 +47,17 @@ export const ReviewMenuItem = ({ if (!answerId && !submitId) return; const answerByRoute = answerDates.find( - (answer) => answer.answerId === answerId || answer.submitId === submitId, + (answer) => + (answerId && answer.answerId === answerId) || (submitId && answer.submitId === submitId), ); if (!answerByRoute) return; setIsOpen(true); - if (selectedAnswer) return; + const isAnswerByRouteSelected = + (answerId && selectedAnswer?.answerId === answerId) || + (submitId && selectedAnswer?.submitId === submitId); + if (isAnswerByRouteSelected) return; onSelectAnswer({ answer: answerByRoute, isRouteCreated: true }); onSelectItem(item); From 9abddf4d7c68828bb79251b78dc22dd9a64f41d3 Mon Sep 17 00:00:00 2001 From: Chris Lei Date: Wed, 29 Jul 2026 10:48:14 -0700 Subject: [PATCH 3/4] Remount review screen on participant change Previously, the review screen kept its state across participants and did not refresh if the date remained unchanged. Now, the review screen refreshes on subjectId + (activityId | activityFlowId). --- .../RespondentDataReview.test.tsx | 71 +++++++++++++++++++ .../RespondentDataReview.tsx | 10 ++- 2 files changed, 80 insertions(+), 1 deletion(-) diff --git a/src/modules/Dashboard/features/RespondentData/RespondentDataReview/RespondentDataReview.test.tsx b/src/modules/Dashboard/features/RespondentData/RespondentDataReview/RespondentDataReview.test.tsx index 85cb73a415..589bde09f0 100644 --- a/src/modules/Dashboard/features/RespondentData/RespondentDataReview/RespondentDataReview.test.tsx +++ b/src/modules/Dashboard/features/RespondentData/RespondentDataReview/RespondentDataReview.test.tsx @@ -1,6 +1,7 @@ import { waitFor, screen, act } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { FormProvider, useForm } from 'react-hook-form'; +import { Link } from 'react-router-dom'; import { PreloadedState } from '@reduxjs/toolkit'; import { vi, type Mock } from 'vitest'; @@ -10,6 +11,7 @@ import { mockedAppletId, mockedCurrentWorkspace, mockedFullSubjectId1, + mockedFullSubjectId2, } from 'shared/mock'; import { Roles, JEST_TEST_TIMEOUT, MAX_LIMIT, ParticipantTag } from 'shared/consts'; import { initialStateData } from 'shared/state'; @@ -27,6 +29,7 @@ const activity2Id = '2'; const routePath = page.appletParticipantActivityDetailsDataReview; const route1 = `/dashboard/${mockedAppletId}/participants/${mockedFullSubjectId1}/activities/${activity1Id}/responses?selectedDate=2023-12-27`; const route2 = `/dashboard/${mockedAppletId}/participants/${mockedFullSubjectId1}/activities/${activity2Id}/responses?selectedDate=2023-12-15&answerId=answer-id-2-2&isFeedbackVisible=true`; +const route3 = `/dashboard/${mockedAppletId}/participants/${mockedFullSubjectId2}/activities/${activity1Id}/responses?selectedDate=2023-12-27`; // route1 with different participant const routeWithoutSelectedDate = `/dashboard/${mockedAppletId}/participants/${mockedFullSubjectId1}/activities/${activity1Id}/responses`; const preloadedState: PreloadedState = { workspaces: { @@ -784,6 +787,74 @@ describe('RespondentDataReview', () => { }); }); + test('renders component after navigation to different participant', async () => { + const getMock = authApiClient.get as unknown as Mock; + getMock.mockImplementation((url: string) => { + if (url.endsWith(`/answers/applet/${mockedAppletId}/dates`)) { + return Promise.resolve(mockedGetWithDates); + } + if (url.endsWith(`/answers/applet/${mockedAppletId}/review/flows`)) { + return Promise.resolve(mockedGetWithFlows1); + } + if (url.endsWith(`/answers/applet/${mockedAppletId}/review/activities`)) { + return Promise.resolve(mockedGetWithActivities2); + } + if (url.includes('/assessment')) { + return Promise.resolve(mockAssessment); + } + + return Promise.resolve(mockedGetWithResponses); + }); + + const getDecryptedActivityDataMock = vi.fn().mockReturnValue(mockDecryptedActivityData); + + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + dashboardHooks.useDecryptedActivityData.mockReturnValue(getDecryptedActivityDataMock); + + window.HTMLElement.prototype.scrollTo = () => {}; + + renderWithProviders( + <> + + Navigate + + + , + { + preloadedState, + route: route1, + routePath, + }, + ); + + await waitFor(() => { + expect(authApiClient.get).toHaveBeenCalledWith( + `/answers/applet/${mockedAppletId}/review/activities`, + expect.objectContaining({ + params: expect.objectContaining({ + createdDate: '2023-12-27', + targetSubjectId: mockedFullSubjectId1, + }), + }), + ); + }); + + await userEvent.click(screen.getByTestId('navigate-to-participant-2')); + + await waitFor(() => { + expect(authApiClient.get).toHaveBeenCalledWith( + `/answers/applet/${mockedAppletId}/review/activities`, + expect.objectContaining({ + params: expect.objectContaining({ + createdDate: '2023-12-27', + targetSubjectId: mockedFullSubjectId2, + }), + }), + ); + }); + }); + test('test if default review date is equal to last activity completed date', async () => { // This test verifies that when a route includes a selectedDate matching the user's // lastSeen/lastActivityCompleted date, the date picker component renders correctly diff --git a/src/modules/Dashboard/features/RespondentData/RespondentDataReview/RespondentDataReview.tsx b/src/modules/Dashboard/features/RespondentData/RespondentDataReview/RespondentDataReview.tsx index 19a8bb7372..b96f5f3d3b 100644 --- a/src/modules/Dashboard/features/RespondentData/RespondentDataReview/RespondentDataReview.tsx +++ b/src/modules/Dashboard/features/RespondentData/RespondentDataReview/RespondentDataReview.tsx @@ -43,7 +43,7 @@ import { ReviewMenu } from './ReviewMenu'; import { useActivityAnswersAndAssessment } from './hooks/useActivityAnswersAndAssessment/useActivityAnswersAndAssessment'; import { useReviewActivitiesAndFlows } from './hooks/useReviewActivitiesAndFlows/useReviewActivitiesAndFlows'; -export const RespondentDataReview = () => { +const RespondentDataReviewInner = () => { const { appletId, subjectId, activityId, activityFlowId } = useParams(); const [searchParams, setSearchParams] = useSearchParams(); const answerId = searchParams.get('answerId') || null; @@ -379,3 +379,11 @@ export const RespondentDataReview = () => { ); }; + +export const RespondentDataReview = () => { + const { subjectId, activityId, activityFlowId } = useParams(); + + // Switching participant or activity keeps the same route pattern, so remount to + // drop the previous selection instead of leaving stale dates, answers and responses + return ; +}; From 3cfdd64b8e08754d376b217d0c0a789844c7d458 Mon Sep 17 00:00:00 2001 From: Chris Lei Date: Mon, 3 Aug 2026 14:00:30 -0700 Subject: [PATCH 4/4] Set last submit date if selected date unavailable --- .../RespondentDataReview/RespondentDataReview.tsx | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/src/modules/Dashboard/features/RespondentData/RespondentDataReview/RespondentDataReview.tsx b/src/modules/Dashboard/features/RespondentData/RespondentDataReview/RespondentDataReview.tsx index b96f5f3d3b..d371f74791 100644 --- a/src/modules/Dashboard/features/RespondentData/RespondentDataReview/RespondentDataReview.tsx +++ b/src/modules/Dashboard/features/RespondentData/RespondentDataReview/RespondentDataReview.tsx @@ -271,11 +271,15 @@ const RespondentDataReviewInner = () => { } const selectedDate = parseDateToMidnightLocal(selectedDateParam); - if ( - prevSelectedDateRef.current !== selectedDateParam && - responseDates.some((date) => date.getTime() === selectedDate.getTime()) - ) { - handleSetInitialDate(selectedDate); + if (prevSelectedDateRef.current !== selectedDateParam) { + // Set last submit date if selected date is unavailable + const hasSelectedDate = responseDates.some( + (date) => date.getTime() === selectedDate.getTime(), + ); + + handleSetInitialDate( + hasSelectedDate ? selectedDate : responseDates[responseDates.length - 1], + ); } }, [responseDates, selectedDateParam, handleSetInitialDate]);