From fda8b9282ffcf28a65317d0db74b9822e2dadbe0 Mon Sep 17 00:00:00 2001 From: alexd-bes <129009580+alexd-bes@users.noreply.github.com> Date: Tue, 14 May 2024 10:51:33 +1200 Subject: [PATCH 01/25] WIP --- packages/datatrak-web-server/src/constants.ts | 1 + .../src/routes/SingleSurveyResponseRoute.ts | 34 +++++- .../src/routes/UserRoute.ts | 3 +- .../src/api/CurrentUserContext.tsx | 7 +- .../datatrak-web/src/api/mutations/index.ts | 1 + .../mutations/useResubmitSurveyResponse.ts | 25 +++++ .../src/api/queries/useSurveyResponse.ts | 16 ++- .../src/components/ErrorDisplay.tsx | 6 +- packages/datatrak-web/src/constants/url.ts | 31 +++++- .../Survey/Components/SurveyPaginator.tsx | 29 +++-- .../SurveySideMenu/SurveySideMenu.tsx | 8 +- .../Survey/SurveyContext/SurveyContext.tsx | 4 + .../src/features/Survey/SurveyLayout.tsx | 32 ++---- .../src/features/Survey/useSurveyRouting.ts | 48 +++++++++ .../src/routes/ResubmitResponseRoute.tsx | 36 +++++++ packages/datatrak-web/src/routes/Routes.tsx | 2 + .../datatrak-web/src/routes/SurveyRoutes.tsx | 7 +- packages/datatrak-web/src/views/ErrorPage.tsx | 6 +- .../src/views/NotAuthorisedPage.tsx | 23 ++++ .../src/views/ResubmitSurveyResponsePage.jsx | 102 ++++++++++++++++++ packages/datatrak-web/src/views/index.ts | 1 + 21 files changed, 375 insertions(+), 47 deletions(-) create mode 100644 packages/datatrak-web-server/src/constants.ts create mode 100644 packages/datatrak-web/src/api/mutations/useResubmitSurveyResponse.ts create mode 100644 packages/datatrak-web/src/features/Survey/useSurveyRouting.ts create mode 100644 packages/datatrak-web/src/routes/ResubmitResponseRoute.tsx create mode 100644 packages/datatrak-web/src/views/NotAuthorisedPage.tsx create mode 100644 packages/datatrak-web/src/views/ResubmitSurveyResponsePage.jsx diff --git a/packages/datatrak-web-server/src/constants.ts b/packages/datatrak-web-server/src/constants.ts new file mode 100644 index 0000000000..c3baf74ccd --- /dev/null +++ b/packages/datatrak-web-server/src/constants.ts @@ -0,0 +1 @@ +export const TUPAIA_ADMIN_PANEL_PERMISSION_GROUP = 'Tupaia Admin Panel'; diff --git a/packages/datatrak-web-server/src/routes/SingleSurveyResponseRoute.ts b/packages/datatrak-web-server/src/routes/SingleSurveyResponseRoute.ts index a7dcacbb1f..3edf8e9e0c 100644 --- a/packages/datatrak-web-server/src/routes/SingleSurveyResponseRoute.ts +++ b/packages/datatrak-web-server/src/routes/SingleSurveyResponseRoute.ts @@ -7,6 +7,9 @@ import { Request } from 'express'; import camelcaseKeys from 'camelcase-keys'; import { Route } from '@tupaia/server-boilerplate'; import { DatatrakWebSingleSurveyResponseRequest } from '@tupaia/types'; +import { AccessPolicy } from '@tupaia/access-policy'; +import { TUPAIA_ADMIN_PANEL_PERMISSION_GROUP } from '../constants'; +import { PermissionsError } from '@tupaia/utils'; export type SingleSurveyResponseRequest = Request< DatatrakWebSingleSurveyResponseRequest.Params, @@ -25,19 +28,46 @@ const DEFAULT_FIELDS = [ 'id', 'survey.name', 'survey.code', + 'user_id', + 'country.code', ]; +const BES_ADMIN_PERMISSION_GROUP = 'BES Admin'; + +// If the user is not a BES admin or does not have access to the admin panel, they should not be able to view the survey response +const assertCanViewSurveyResponse = (accessPolicy: AccessPolicy, countryCode: string) => { + const isBESAdmin = accessPolicy.allowsSome(undefined, BES_ADMIN_PERMISSION_GROUP); + const hasAdminPanelAccess = accessPolicy.allows(countryCode, TUPAIA_ADMIN_PANEL_PERMISSION_GROUP); + if (!isBESAdmin && !hasAdminPanelAccess) { + throw new PermissionsError('You do not have access to view this survey response'); + } +}; + export class SingleSurveyResponseRoute extends Route { public async buildResponse() { - const { ctx, params, query } = this.req; + const { ctx, params, query, models, accessPolicy } = this.req; const { id: responseId } = params; const { fields = DEFAULT_FIELDS } = query; + const currentUser = await ctx.services.central.getUser(); + const { id } = currentUser; + const surveyResponse = await ctx.services.central.fetchResources( `surveyResponses/${responseId}`, { columns: fields }, ); + + if (!surveyResponse) { + throw new Error(`Survey response with id ${responseId} not found`); + } + + const { user_id: userId, 'country.code': countryCode, ...response } = surveyResponse; + + if (userId !== id) { + assertCanViewSurveyResponse(accessPolicy, countryCode); + } + const answerList = await ctx.services.central.fetchResources('answers', { filter: { survey_response_id: surveyResponse.id }, columns: ANSWER_COLUMNS, @@ -51,6 +81,6 @@ export class SingleSurveyResponseRoute extends Route; -const TUPAIA_ADMIN_PANEL_PERMISSION_GROUP = 'Tupaia Admin Panel'; - export class UserRoute extends Route { public async buildResponse() { const { ctx, session, accessPolicy } = this.req; diff --git a/packages/datatrak-web/src/api/CurrentUserContext.tsx b/packages/datatrak-web/src/api/CurrentUserContext.tsx index eab9335b5f..9c4840a901 100644 --- a/packages/datatrak-web/src/api/CurrentUserContext.tsx +++ b/packages/datatrak-web/src/api/CurrentUserContext.tsx @@ -28,7 +28,12 @@ export const CurrentUserContextProvider = ({ children }: { children: React.React } if (currentUserQuery.isError) { - return ; + return ( + + ); } const data = currentUserQuery.data; diff --git a/packages/datatrak-web/src/api/mutations/index.ts b/packages/datatrak-web/src/api/mutations/index.ts index 74e16c9a57..b146b4a987 100644 --- a/packages/datatrak-web/src/api/mutations/index.ts +++ b/packages/datatrak-web/src/api/mutations/index.ts @@ -16,3 +16,4 @@ export { useRequestDeleteAccount } from './useRequestDeleteAccount'; export { useOneTimeLogin } from './useOneTimeLogin'; export * from './useExportSurveyResponses'; export { useTupaiaRedirect } from './useTupaiaRedirect'; +export { useResubmitSurveyResponse } from './useResubmitSurveyResponse'; diff --git a/packages/datatrak-web/src/api/mutations/useResubmitSurveyResponse.ts b/packages/datatrak-web/src/api/mutations/useResubmitSurveyResponse.ts new file mode 100644 index 0000000000..ebf6e8d09a --- /dev/null +++ b/packages/datatrak-web/src/api/mutations/useResubmitSurveyResponse.ts @@ -0,0 +1,25 @@ +/* + * Tupaia + * Copyright (c) 2017 - 2024 Beyond Essential Systems Pty Ltd + */ + +import { useMutation, useQueryClient } from 'react-query'; +import { useParams } from 'react-router'; + +type Answer = string | number | boolean | null | undefined; + +export type AnswersT = Record; + +export const useResubmitSurveyResponse = () => { + const { surveyResponseId } = useParams(); + return useMutation(async (answers: AnswersT) => { + if (!answers) { + return; + } + console.log('surveyResponseId', answers); + + // return post('submitSurvey', { + // data: { ...surveyResponseData, answers }, + // }); + }); +}; diff --git a/packages/datatrak-web/src/api/queries/useSurveyResponse.ts b/packages/datatrak-web/src/api/queries/useSurveyResponse.ts index 601d39b552..9030e94731 100644 --- a/packages/datatrak-web/src/api/queries/useSurveyResponse.ts +++ b/packages/datatrak-web/src/api/queries/useSurveyResponse.ts @@ -3,14 +3,28 @@ * Copyright (c) 2017 - 2023 Beyond Essential Systems Pty Ltd */ import { useQuery } from 'react-query'; +import { useNavigate } from 'react-router'; import { DatatrakWebSingleSurveyResponseRequest } from '@tupaia/types'; import { get } from '../api'; +import { ROUTES } from '../../constants'; +import { errorToast } from '../../utils'; export const useSurveyResponse = (surveyResponseId?: string) => { + const navigate = useNavigate(); return useQuery( ['surveyResponse', surveyResponseId], (): Promise => get(`surveyResponse/${surveyResponseId}`), - { enabled: !!surveyResponseId }, + { + enabled: !!surveyResponseId, + meta: { + applyCustomErrorHandling: true, + }, + onError(error: any) { + if (error.code === 403) + return navigate(ROUTES.NOT_AUTHORISED, { state: { errorMessage: error.message } }); + errorToast(error.message); + }, + }, ); }; diff --git a/packages/datatrak-web/src/components/ErrorDisplay.tsx b/packages/datatrak-web/src/components/ErrorDisplay.tsx index ff8d813b06..0e273224ba 100644 --- a/packages/datatrak-web/src/components/ErrorDisplay.tsx +++ b/packages/datatrak-web/src/components/ErrorDisplay.tsx @@ -33,11 +33,11 @@ const Container = styled(Paper).attrs({ `; export const ErrorDisplay = ({ - error, + errorMessage, children, title, }: { - error?: Error | null; + errorMessage?: string | null; children?: ReactNode; title; }) => { @@ -45,7 +45,7 @@ export const ErrorDisplay = ({ {title} - {error && {error.message}} + {errorMessage && {errorMessage}} {children} diff --git a/packages/datatrak-web/src/constants/url.ts b/packages/datatrak-web/src/constants/url.ts index 02432680f8..6b31a1411b 100644 --- a/packages/datatrak-web/src/constants/url.ts +++ b/packages/datatrak-web/src/constants/url.ts @@ -1,10 +1,12 @@ /* * Tupaia - * Copyright (c) 2017 - 2023 Beyond Essential Systems Pty Ltd + * Copyright (c) 2017 - 2024 Beyond Essential Systems Pty Ltd */ const SURVEY_URL = '/survey/:countryCode/:surveyCode'; +const SURVEY_RESUBMIT_BASE_URL = `${SURVEY_URL}/resubmit/:surveyResponseId`; + export const ROUTES = { HOME: '/', LOGIN: '/login', @@ -19,13 +21,38 @@ export const ROUTES = { SURVEY_SUCCESS: `${SURVEY_URL}/success`, SURVEY_REVIEW: `${SURVEY_URL}/review`, SURVEY_RESPONSE: `${SURVEY_URL}/response/:surveyResponseId`, + SURVEY_RESUBMIT: SURVEY_RESUBMIT_BASE_URL, + SURVEY_RESUBMIT_SCREEN: `${SURVEY_RESUBMIT_BASE_URL}/screen/:screenNumber`, + SURVEY_RESUBMIT_REVIEW: `${SURVEY_RESUBMIT_BASE_URL}/review`, ACCOUNT_SETTINGS: '/account-settings', CHANGE_PROJECT: '/change-project', VERIFY_EMAIL: '/verify-email', VERIFY_EMAIL_RESEND: '/verify-email-resend', REPORTS: '/reports', + NOT_AUTHORISED: '/not-authorised', }; export const PASSWORD_RESET_TOKEN_PARAM = 'passwordResetToken'; -export const ADMIN_ONLY_ROUTES = [ROUTES.REPORTS]; +export const ADMIN_ONLY_ROUTES = [ROUTES.REPORTS, ROUTES.SURVEY_RESUBMIT_SCREEN]; + +export const SURVEY_ROUTE_OBJECTS = [ + { + path: ROUTES.SURVEY_SCREEN, + }, + { + path: ROUTES.SURVEY_SUCCESS, + }, + { + path: ROUTES.SURVEY_REVIEW, + }, + { + path: ROUTES.SURVEY_RESPONSE, + }, + { + path: ROUTES.SURVEY_RESUBMIT_SCREEN, + }, + { + path: ROUTES.SURVEY_RESUBMIT_REVIEW, + }, +]; diff --git a/packages/datatrak-web/src/features/Survey/Components/SurveyPaginator.tsx b/packages/datatrak-web/src/features/Survey/Components/SurveyPaginator.tsx index 53ed5859b1..2c7ab347e7 100644 --- a/packages/datatrak-web/src/features/Survey/Components/SurveyPaginator.tsx +++ b/packages/datatrak-web/src/features/Survey/Components/SurveyPaginator.tsx @@ -11,9 +11,11 @@ import { useSurveyForm } from '../SurveyContext'; import { Button } from '../../../components'; import { useIsMobile } from '../../../utils'; -const FormActions = styled.div` +const FormActions = styled.div<{ + $hasBackButton: boolean; +}>` display: flex; - justify-content: space-between; + justify-content: ${({ $hasBackButton }) => ($hasBackButton ? 'space-between' : 'flex-end')}; align-items: center; padding: 1rem 0.5rem; border-top: 1px solid ${props => props.theme.palette.divider}; @@ -27,6 +29,7 @@ const FormActions = styled.div` const ButtonGroup = styled.div` display: flex; + float: right; button, a { &:last-child { @@ -48,15 +51,21 @@ const BackButton = styled(Button).attrs({ } `; -type SurveyLayoutContextT = { isLoading: boolean; onStepPrevious: () => void }; +type SurveyLayoutContextT = { + isLoading: boolean; + onStepPrevious: () => void; + hasBackButton: boolean; +}; export const SurveyPaginator = () => { - const { isLast, isReviewScreen, openCancelConfirmation } = useSurveyForm(); + const { isLast, isReviewScreen, openCancelConfirmation, isResubmitReviewScreen } = + useSurveyForm(); const isMobile = useIsMobile(); - const { isLoading, onStepPrevious } = useOutletContext(); + const { isLoading, onStepPrevious, hasBackButton } = useOutletContext(); const getNextButtonText = () => { if (isReviewScreen) return 'Submit'; + if (isResubmitReviewScreen) return 'Resubmit'; if (isLast) { return isMobile ? 'Review' : 'Review and submit'; } @@ -66,10 +75,12 @@ export const SurveyPaginator = () => { const nextButtonText = getNextButtonText(); return ( - - - Back - + + {hasBackButton && ( + + Back + + )} + + + ); +}; diff --git a/packages/datatrak-web/src/features/Survey/Screens/SurveySuccessScreen.tsx b/packages/datatrak-web/src/features/Survey/Screens/SurveySuccessScreen.tsx index ef5cad41b7..c468bd01a2 100644 --- a/packages/datatrak-web/src/features/Survey/Screens/SurveySuccessScreen.tsx +++ b/packages/datatrak-web/src/features/Survey/Screens/SurveySuccessScreen.tsx @@ -6,57 +6,11 @@ import React from 'react'; import { generatePath, useNavigate, useParams } from 'react-router-dom'; import styled from 'styled-components'; -import { Typography } from '@material-ui/core'; import { Button as BaseButton } from '../../../components'; import { useSurveyForm } from '../SurveyContext'; import { ROUTES } from '../../../constants'; import { useSurvey } from '../../../api/queries'; -import { SurveyQRCode } from '../SurveyQRCode'; -import { useCurrentUserContext } from '../../../api'; - -const Wrapper = styled.div` - display: flex; - flex: 1; - height: 100%; -`; -const Container = styled.div` - display: flex; - flex-direction: column; - justify-content: center; - align-items: center; - width: 100%; - ${({ theme }) => theme.breakpoints.up('md')} { - flex: 1; - } -`; - -const StyledImg = styled.img` - aspect-ratio: 1; - width: 23rem; - max-width: 80%; - max-height: 50%; - margin-block-end: 2.75rem; -`; - -const Title = styled(Typography).attrs({ - variant: 'h2', -})` - font-size: 1.375rem; - font-weight: 600; - text-align: center; - margin-block-end: 1rem; - ${({ theme }) => theme.breakpoints.up('md')} { - font-size: 1.9rem; - margin-block-end: 1.19rem; - } -`; - -const Text = styled(Typography)` - max-width: 34.6rem; - width: 100%; - text-align: center; - margin-block-end: 1.875rem; -`; +import { SurveySuccess } from '../Components'; const ButtonGroup = styled.div` max-width: 28rem; @@ -70,7 +24,6 @@ const Button = styled(BaseButton)` `; export const SurveySuccessScreen = () => { - const { isLoggedIn } = useCurrentUserContext(); const params = useParams(); const navigate = useNavigate(); const { resetForm } = useSurveyForm(); @@ -96,27 +49,17 @@ export const SurveySuccessScreen = () => { const text = getText(); return ( - - - - Survey submitted! - {isLoggedIn && ( - <> - {text} - - {survey?.canRepeat && ( - - )} - - - + + + {survey?.canRepeat && ( + )} - - - + + + ); }; diff --git a/packages/datatrak-web/src/features/Survey/Screens/index.ts b/packages/datatrak-web/src/features/Survey/Screens/index.ts index f9b6e909e9..c3bc40c90c 100644 --- a/packages/datatrak-web/src/features/Survey/Screens/index.ts +++ b/packages/datatrak-web/src/features/Survey/Screens/index.ts @@ -5,3 +5,4 @@ export { SurveyReviewScreen } from './SurveyReviewScreen'; export { SurveySuccessScreen } from './SurveySuccessScreen'; export { SurveyScreen } from './SurveyScreen'; +export { SurveyResubmitSuccessScreen } from './SurveyResubmitSuccessScreen'; diff --git a/packages/datatrak-web/src/features/Survey/SurveyContext/SurveyContext.tsx b/packages/datatrak-web/src/features/Survey/SurveyContext/SurveyContext.tsx index eccdffbcc5..51cb302588 100644 --- a/packages/datatrak-web/src/features/Survey/SurveyContext/SurveyContext.tsx +++ b/packages/datatrak-web/src/features/Survey/SurveyContext/SurveyContext.tsx @@ -123,6 +123,7 @@ export const useSurveyForm = () => { const isLast = screenNumber === numberOfScreens; const isSuccessScreen = !!useMatch(ROUTES.SURVEY_SUCCESS); const isReviewScreen = !!useMatch(ROUTES.SURVEY_REVIEW); + const isResubmit = !!useMatch(ROUTES.SURVEY_RESUBMIT); const isResponseScreen = !!useMatch(ROUTES.SURVEY_RESPONSE); const isResubmitScreen = !!useMatch(ROUTES.SURVEY_RESUBMIT_SCREEN); const isResubmitReviewScreen = !!useMatch(ROUTES.SURVEY_RESUBMIT_REVIEW); @@ -173,5 +174,6 @@ export const useSurveyForm = () => { closeCancelConfirmation, isResubmitScreen, isResubmitReviewScreen, + isResubmit, }; }; diff --git a/packages/datatrak-web/src/features/Survey/SurveyLayout.tsx b/packages/datatrak-web/src/features/Survey/SurveyLayout.tsx index ff1774364b..4dab040ac8 100644 --- a/packages/datatrak-web/src/features/Survey/SurveyLayout.tsx +++ b/packages/datatrak-web/src/features/Survey/SurveyLayout.tsx @@ -87,7 +87,8 @@ export const SurveyLayout = () => { } = useSurveyForm(); const { handleSubmit, getValues } = useFormContext(); const { mutate: submitSurvey, isLoading: isSubmittingSurvey } = useSubmitSurvey(); - const { mutate: resubmitSurveyResponse } = useResubmitSurveyResponse(); + const { mutate: resubmitSurveyResponse, isLoading: isResubmittingSurvey } = + useResubmitSurveyResponse(); const { back, next } = useSurveyRouting(numberOfScreens); const handleStep = (path, data) => { @@ -138,14 +139,18 @@ export const SurveyLayout = () => { const handleClickSubmit = handleSubmit(onSubmit, onError); + const showLoader = isSubmittingSurvey || isResubmittingSurvey; + return ( <> - - {isSubmittingSurvey && ( + + {showLoader && ( diff --git a/packages/datatrak-web/src/features/index.ts b/packages/datatrak-web/src/features/index.ts index 22e48c0b2b..787d45e63f 100644 --- a/packages/datatrak-web/src/features/index.ts +++ b/packages/datatrak-web/src/features/index.ts @@ -15,6 +15,7 @@ export { getAllSurveyComponents, SurveySideMenu, useValidationResolver, + SurveyResubmitSuccessScreen, } from './Survey'; export { RequestProjectAccess } from './RequestProjectAccess'; export { MobileAppPrompt } from './MobileAppPrompt'; diff --git a/packages/datatrak-web/src/routes/SurveyRoutes.tsx b/packages/datatrak-web/src/routes/SurveyRoutes.tsx index a71f6edab5..dae4f873af 100644 --- a/packages/datatrak-web/src/routes/SurveyRoutes.tsx +++ b/packages/datatrak-web/src/routes/SurveyRoutes.tsx @@ -13,6 +13,7 @@ import { SurveyReviewScreen, SurveyScreen, SurveySuccessScreen, + SurveyResubmitSuccessScreen, } from '../views'; import { SurveyLayout, useSurveyForm } from '../features'; import { useCurrentUserContext, useSurvey } from '../api'; @@ -73,6 +74,7 @@ export const SurveyRoutes = ( > } /> } /> + } /> }> } /> Date: Tue, 14 May 2024 15:16:57 +1200 Subject: [PATCH 06/25] Loading state --- packages/datatrak-web/src/features/Survey/SurveyLayout.tsx | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/packages/datatrak-web/src/features/Survey/SurveyLayout.tsx b/packages/datatrak-web/src/features/Survey/SurveyLayout.tsx index 4dab040ac8..d755fe92ec 100644 --- a/packages/datatrak-web/src/features/Survey/SurveyLayout.tsx +++ b/packages/datatrak-web/src/features/Survey/SurveyLayout.tsx @@ -147,9 +147,7 @@ export const SurveyLayout = () => { - + {showLoader && ( From 4f137ccec96a9627533f5a3dbefa14dcba07756d Mon Sep 17 00:00:00 2001 From: alexd-bes <129009580+alexd-bes@users.noreply.github.com> Date: Tue, 14 May 2024 15:21:04 +1200 Subject: [PATCH 07/25] Disable create new autocomplete abilities in resubmit (temp) --- .../src/features/Questions/AutocompleteQuestion.tsx | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/packages/datatrak-web/src/features/Questions/AutocompleteQuestion.tsx b/packages/datatrak-web/src/features/Questions/AutocompleteQuestion.tsx index a66abdb5e6..95e56e5733 100644 --- a/packages/datatrak-web/src/features/Questions/AutocompleteQuestion.tsx +++ b/packages/datatrak-web/src/features/Questions/AutocompleteQuestion.tsx @@ -12,6 +12,7 @@ import { SurveyQuestionInputProps } from '../../types'; import { useAutocompleteOptions } from '../../api'; import { MOBILE_BREAKPOINT } from '../../constants'; import { Autocomplete as BaseAutocomplete, InputHelperText } from '../../components'; +import { useSurveyForm } from '../Survey'; const Autocomplete = styled(BaseAutocomplete)` width: calc(100% - 3.5rem); @@ -67,6 +68,7 @@ export const AutocompleteQuestion = ({ config = {}, controllerProps: { value: selectedValue = null, onChange, ref, invalid }, }: SurveyQuestionInputProps) => { + const { isResubmit } = useSurveyForm(); const [searchValue, setSearchValue] = useState(''); const { autocomplete = {} } = config!; const { attributes, createNew } = autocomplete; @@ -76,6 +78,9 @@ export const AutocompleteQuestion = ({ searchValue, ); + // TODO: renable this as part of RN-1274 + const canCreateNew = createNew && !isResubmit; + const getOptionSelected = (option: Option, selectedOption?: string | null) => { const value = typeof option === 'string' ? option : option?.value; return value === selectedOption; @@ -84,7 +89,7 @@ export const AutocompleteQuestion = ({ const getOptions = () => { const options = data || []; // If we can't create a new option, or there is no input value, or the input value is already in the options, or the value is already added, return the options as they are - if (!createNew || !searchValue || options.find(option => option.value === searchValue)) + if (!canCreateNew || !searchValue || options.find(option => option.value === searchValue)) return options; // if we have selected a newly created option, add it to the list of options if (selectedValue?.value === searchValue) From b16feb0602b24b37bb152c4a3ca8901919adbc12 Mon Sep 17 00:00:00 2001 From: alexd-bes <129009580+alexd-bes@users.noreply.github.com> Date: Tue, 14 May 2024 15:25:22 +1200 Subject: [PATCH 08/25] tidy ups --- packages/datatrak-web-server/src/constants.ts | 4 ++++ packages/datatrak-web/src/constants/url.ts | 21 ------------------- 2 files changed, 4 insertions(+), 21 deletions(-) diff --git a/packages/datatrak-web-server/src/constants.ts b/packages/datatrak-web-server/src/constants.ts index c3baf74ccd..fdc1e10866 100644 --- a/packages/datatrak-web-server/src/constants.ts +++ b/packages/datatrak-web-server/src/constants.ts @@ -1 +1,5 @@ +/** + * Tupaia + * Copyright (c) 2017 - 2024 Beyond Essential Systems Pty Ltd + */ export const TUPAIA_ADMIN_PANEL_PERMISSION_GROUP = 'Tupaia Admin Panel'; diff --git a/packages/datatrak-web/src/constants/url.ts b/packages/datatrak-web/src/constants/url.ts index 21118f41c8..21f14b94df 100644 --- a/packages/datatrak-web/src/constants/url.ts +++ b/packages/datatrak-web/src/constants/url.ts @@ -36,24 +36,3 @@ export const ROUTES = { export const PASSWORD_RESET_TOKEN_PARAM = 'passwordResetToken'; export const ADMIN_ONLY_ROUTES = [ROUTES.REPORTS, ROUTES.SURVEY_RESUBMIT_SCREEN]; - -export const SURVEY_ROUTE_OBJECTS = [ - { - path: ROUTES.SURVEY_SCREEN, - }, - { - path: ROUTES.SURVEY_SUCCESS, - }, - { - path: ROUTES.SURVEY_REVIEW, - }, - { - path: ROUTES.SURVEY_RESPONSE, - }, - { - path: ROUTES.SURVEY_RESUBMIT_SCREEN, - }, - { - path: ROUTES.SURVEY_RESUBMIT_REVIEW, - }, -]; From dc248927f12a6ec21a17f34dd2ced2de9f2a55c0 Mon Sep 17 00:00:00 2001 From: alexd-bes <129009580+alexd-bes@users.noreply.github.com> Date: Tue, 14 May 2024 15:37:06 +1200 Subject: [PATCH 09/25] Remove unused import --- .../src/features/Survey/Screens/SurveyResubmitSuccessScreen.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/datatrak-web/src/features/Survey/Screens/SurveyResubmitSuccessScreen.tsx b/packages/datatrak-web/src/features/Survey/Screens/SurveyResubmitSuccessScreen.tsx index 4b4e71bbc2..6d0df85b9f 100644 --- a/packages/datatrak-web/src/features/Survey/Screens/SurveyResubmitSuccessScreen.tsx +++ b/packages/datatrak-web/src/features/Survey/Screens/SurveyResubmitSuccessScreen.tsx @@ -5,7 +5,6 @@ import React from 'react'; import styled from 'styled-components'; -import { Link } from '@material-ui/core'; import { Button } from '../../../components'; import { SurveySuccess } from '../Components'; From a1484f77b6bd25c97e30a56ddb8c500dec4d75a8 Mon Sep 17 00:00:00 2001 From: alexd-bes <129009580+alexd-bes@users.noreply.github.com> Date: Tue, 14 May 2024 15:44:26 +1200 Subject: [PATCH 10/25] Fix imports --- packages/datatrak-web/src/features/Survey/SurveyLayout.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/datatrak-web/src/features/Survey/SurveyLayout.tsx b/packages/datatrak-web/src/features/Survey/SurveyLayout.tsx index d755fe92ec..f81cd2fe89 100644 --- a/packages/datatrak-web/src/features/Survey/SurveyLayout.tsx +++ b/packages/datatrak-web/src/features/Survey/SurveyLayout.tsx @@ -3,17 +3,17 @@ * Copyright (c) 2017 - 2023 Beyond Essential Systems Pty Ltd */ -import React, { useEffect } from 'react'; +import React from 'react'; import { Outlet, generatePath, useNavigate, useParams } from 'react-router'; import { useFormContext } from 'react-hook-form'; import styled from 'styled-components'; import { Paper as MuiPaper } from '@material-ui/core'; import { SpinningLoader } from '@tupaia/ui-components'; +import { ROUTES } from '../../constants'; +import { useResubmitSurveyResponse, useSubmitSurvey } from '../../api/mutations'; import { SurveyParams } from '../../types'; import { useSurveyForm } from './SurveyContext'; import { SIDE_MENU_WIDTH, SurveySideMenu } from './Components'; -import { ROUTES } from '../../constants'; -import { useResubmitSurveyResponse, useSubmitSurvey } from '../../api/mutations'; import { getErrorsByScreen } from './utils'; import { useSurveyRouting } from './useSurveyRouting'; From 2fc2a805089a2e172472938fbc398fb1090423a4 Mon Sep 17 00:00:00 2001 From: alexd-bes <129009580+alexd-bes@users.noreply.github.com> Date: Tue, 14 May 2024 15:52:33 +1200 Subject: [PATCH 11/25] Update SurveyResponsePage.tsx --- packages/datatrak-web/src/views/SurveyResponsePage.tsx | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/packages/datatrak-web/src/views/SurveyResponsePage.tsx b/packages/datatrak-web/src/views/SurveyResponsePage.tsx index cdf7accd5c..a26b22f878 100644 --- a/packages/datatrak-web/src/views/SurveyResponsePage.tsx +++ b/packages/datatrak-web/src/views/SurveyResponsePage.tsx @@ -1,10 +1,9 @@ /* * Tupaia - * Copyright (c) 2017 - 2023 Beyond Essential Systems Pty Ltd + * Copyright (c) 2017 - 2024 Beyond Essential Systems Pty Ltd */ -import React, { useEffect } from 'react'; -import { useFormContext } from 'react-hook-form'; +import React from 'react'; import { useParams } from 'react-router-dom'; import styled from 'styled-components'; import { Typography } from '@material-ui/core'; @@ -13,7 +12,6 @@ import { useSurveyResponse } from '../api/queries'; import { SurveyReviewSection } from '../features/Survey/Components'; import { Button, SurveyTickIcon } from '../components'; import { displayDate } from '../utils'; -import { useSurveyForm } from '../features'; const Header = styled.div` display: flex; From ecd969578c6af4b95827853785a5c8ab6e5f2982 Mon Sep 17 00:00:00 2001 From: alexd-bes <129009580+alexd-bes@users.noreply.github.com> Date: Tue, 14 May 2024 16:14:06 +1200 Subject: [PATCH 12/25] Fix tests --- packages/datatrak-web/src/api/mutations/useSubmitSurvey.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/datatrak-web/src/api/mutations/useSubmitSurvey.ts b/packages/datatrak-web/src/api/mutations/useSubmitSurvey.ts index 2516290988..ac32fe5f1a 100644 --- a/packages/datatrak-web/src/api/mutations/useSubmitSurvey.ts +++ b/packages/datatrak-web/src/api/mutations/useSubmitSurvey.ts @@ -68,8 +68,9 @@ const createEncodedFile = (fileObject?: File): Promise => { const processAnswers = async (answers: AnswersT, questionsById) => { const formattedAnswers = { ...answers }; for (const [questionId, answer] of Object.entries(answers)) { - const { type } = questionsById[questionId]; - if (type === QuestionType.File && isFileUploadAnswer(answer)) { + const question = questionsById[questionId]; + if (!question) continue; + if (question.type === QuestionType.File && isFileUploadAnswer(answer)) { // convert to an object with an encoded file so that it can be handled in the backend and uploaded to s3 const encodedFile = await createEncodedFile(answer.value as File); formattedAnswers[questionId] = { From 79532d8b5b3960486b77e3f85fa269d528f2a0c5 Mon Sep 17 00:00:00 2001 From: alexd-bes <129009580+alexd-bes@users.noreply.github.com> Date: Thu, 16 May 2024 08:48:25 +1200 Subject: [PATCH 13/25] Update packages/datatrak-web-server/src/routes/SingleSurveyResponseRoute.ts Co-authored-by: Rohan Port <59544282+rohan-bes@users.noreply.github.com> --- .../datatrak-web-server/src/routes/SingleSurveyResponseRoute.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/datatrak-web-server/src/routes/SingleSurveyResponseRoute.ts b/packages/datatrak-web-server/src/routes/SingleSurveyResponseRoute.ts index 3edf8e9e0c..93917f89de 100644 --- a/packages/datatrak-web-server/src/routes/SingleSurveyResponseRoute.ts +++ b/packages/datatrak-web-server/src/routes/SingleSurveyResponseRoute.ts @@ -45,7 +45,7 @@ const assertCanViewSurveyResponse = (accessPolicy: AccessPolicy, countryCode: st export class SingleSurveyResponseRoute extends Route { public async buildResponse() { - const { ctx, params, query, models, accessPolicy } = this.req; + const { ctx, params, query, accessPolicy } = this.req; const { id: responseId } = params; const { fields = DEFAULT_FIELDS } = query; From 99c064cb5fe25350eb4070ba89ffb5ce105caa50 Mon Sep 17 00:00:00 2001 From: alexd-bes <129009580+alexd-bes@users.noreply.github.com> Date: Thu, 16 May 2024 09:01:23 +1200 Subject: [PATCH 14/25] feat(adminPanel): RN-1243: Resubmit survey response modal should link to Datatrak Web (#5640) * WIP * WIP * WIP * Styling * WIP * Error dismiss * WIP * Use updated entity country code if applicable * Don't update user project/country if in resubmit mode * Fix build * Add REACT_APP_DATATRAK_WEB_URL to .env.example --- packages/admin-panel/.env.example | 1 + .../VizBuilderApp/api/queries/useEntities.js | 2 +- .../mutations/useResubmitSurveyResponse.js | 26 ++- .../admin-panel/src/surveyResponse/Form.jsx | 184 ++++++++---------- .../src/surveyResponse/ResponseFields.jsx | 114 +++++++---- .../ResubmitSurveyResponseModal.jsx | 4 +- .../Survey/SurveyContext/SurveyContext.tsx | 4 +- .../datatrak-web/src/routes/SurveyRoutes.tsx | 31 ++- .../datatrak-web/src/views/SurveyPage.tsx | 52 ++--- .../src/components/Inputs/TextField.tsx | 1 - 10 files changed, 226 insertions(+), 193 deletions(-) diff --git a/packages/admin-panel/.env.example b/packages/admin-panel/.env.example index 4236bd19c7..6f659beb92 100644 --- a/packages/admin-panel/.env.example +++ b/packages/admin-panel/.env.example @@ -3,3 +3,4 @@ REACT_APP_CLIENT_BASIC_AUTH_HEADER= REACT_APP_VIZ_BUILDER_API_URL= SKIP_PREFLIGHT_CHECK= PARSE_LINK_HEADER_MAXLEN= +REACT_APP_DATATRAK_WEB_URL= diff --git a/packages/admin-panel/src/VizBuilderApp/api/queries/useEntities.js b/packages/admin-panel/src/VizBuilderApp/api/queries/useEntities.js index 3058797932..cfae2a3c0b 100644 --- a/packages/admin-panel/src/VizBuilderApp/api/queries/useEntities.js +++ b/packages/admin-panel/src/VizBuilderApp/api/queries/useEntities.js @@ -12,7 +12,7 @@ export const useEntities = search => ['entities', search], async () => { const endpoint = stringifyQuery(undefined, `entities`, { - columns: JSON.stringify(['name', 'code', 'id']), + columns: JSON.stringify(['name', 'code', 'id', 'country_code']), filter: JSON.stringify({ name: { comparator: 'ilike', comparisonValue: `%${search}%`, castAs: 'text' }, }), diff --git a/packages/admin-panel/src/api/mutations/useResubmitSurveyResponse.js b/packages/admin-panel/src/api/mutations/useResubmitSurveyResponse.js index 840929d77b..b42418f53c 100644 --- a/packages/admin-panel/src/api/mutations/useResubmitSurveyResponse.js +++ b/packages/admin-panel/src/api/mutations/useResubmitSurveyResponse.js @@ -1,30 +1,26 @@ /* * Tupaia - * Copyright (c) 2017 - 20211Beyond Essential Systems Pty Ltd - * + * Copyright (c) 2017 - 2024 Beyond Essential Systems Pty Ltd */ -import { useMutation } from 'react-query'; + +import { useMutation, useQueryClient } from 'react-query'; import { useApiContext } from '../../utilities/ApiProvider'; -export const useResubmitSurveyResponse = ( - surveyResponseId, - updatedSurveyResponse, - filesByQuestionCode, -) => { +export const useResubmitSurveyResponse = (surveyResponseId, updatedSurveyResponse) => { + const queryClient = useQueryClient(); const api = useApiContext(); return useMutation( [`surveyResubmit`, surveyResponseId, updatedSurveyResponse], () => { - return api.multipartPost({ - endpoint: `surveyResponse/${surveyResponseId}/resubmit`, - filesByMultipartKey: filesByQuestionCode, - payload: { - ...updatedSurveyResponse, - }, - }); + return api.post(`surveyResponse/${surveyResponseId}/resubmit`, null, updatedSurveyResponse); }, { throwOnError: true, + onSuccess: async () => { + // invalidate the survey response data + await queryClient.invalidateQueries(['surveyResubmitData', surveyResponseId]); + return 'completed'; + }, }, ); }; diff --git a/packages/admin-panel/src/surveyResponse/Form.jsx b/packages/admin-panel/src/surveyResponse/Form.jsx index e69f9dcd61..31d55c8f74 100644 --- a/packages/admin-panel/src/surveyResponse/Form.jsx +++ b/packages/admin-panel/src/surveyResponse/Form.jsx @@ -5,53 +5,39 @@ import React, { useState, useCallback, useEffect } from 'react'; import PropTypes from 'prop-types'; +import styled from 'styled-components'; import { Button } from '@tupaia/ui-components'; -import { Divider } from '@material-ui/core'; import { useGetExistingData } from './useGetExistingData'; import { ModalContentProvider, ModalFooter } from '../widgets'; import { useResubmitSurveyResponse } from '../api/mutations/useResubmitSurveyResponse'; -import { MODAL_STATUS } from './constants'; -import { SurveyScreens } from './SurveyScreens'; import { ResponseFields } from './ResponseFields'; +const ButtonGroup = styled.div` + display: flex; + justify-content: space-between; + width: 100%; +`; + export const Form = ({ surveyResponseId, onDismiss, onAfterMutate }) => { const [surveyResubmission, setSurveyResubmission] = useState({}); - const [filesByQuestionCode, setFilesByQuestionCode] = useState({}); const isUnchanged = Object.keys(surveyResubmission).length === 0; - const [resubmitStatus, setResubmitStatus] = useState(MODAL_STATUS.INITIAL); const [selectedEntity, setSelectedEntity] = useState({}); - const [resubmitErrorMessage, setResubmitErrorMessage] = useState(null); - - const useResubmitResponse = () => { - // Swap filesByQuestionCode to filesByUniqueFileName. - // Tracking by question code allows us to manage files easier e.g. don't have to worry about tracking them in deletions - // And the API endpoint needs them by uniqueFileName - const filesByUniqueFileName = {}; - for (const [questionCode, file] of Object.entries(filesByQuestionCode)) { - const uniqueFileName = surveyResubmission.answers[questionCode]; - filesByUniqueFileName[uniqueFileName] = file; - } - return useResubmitSurveyResponse(surveyResponseId, surveyResubmission, filesByUniqueFileName); - }; - const { mutateAsync: resubmitResponse } = useResubmitResponse(); - const handleResubmit = useCallback(async () => { - setResubmitStatus(MODAL_STATUS.LOADING); - try { - await resubmitResponse(); - } catch (e) { - setResubmitStatus(MODAL_STATUS.ERROR); - setResubmitErrorMessage(e.message); - return; - } - setResubmitStatus(MODAL_STATUS.SUCCESS); - onAfterMutate(); - }); + const { + mutateAsync: resubmitResponse, + isLoading, + isError, + error: resubmitError, + reset, // reset the mutation state so we can dismiss the error + isSuccess, + } = useResubmitSurveyResponse(surveyResponseId, surveyResubmission); const { data, isLoading: isFetching, error: fetchError } = useGetExistingData(surveyResponseId); const fetchErrorMessage = fetchError?.message; + const existingAndNewFields = { ...data?.surveyResponse, ...surveyResubmission }; + useEffect(() => { if (!data) { setSelectedEntity({}); @@ -60,88 +46,84 @@ export const Form = ({ surveyResponseId, onDismiss, onAfterMutate }) => { } }, [data]); - const handleDismissError = () => { - setResubmitStatus(MODAL_STATUS.INITIAL); - setResubmitErrorMessage(null); + const resubmitSurveyResponse = async () => { + await resubmitResponse(); + onAfterMutate(); }; - const onSetFormFile = (questionCode, file) => { - setFilesByQuestionCode({ ...filesByQuestionCode, [questionCode]: file }); + const getDatatrakBaseUrl = () => { + if (import.meta.env.REACT_APP_DATATRAK_WEB_URL) + return import.meta.env.REACT_APP_DATATRAK_WEB_URL; + const { origin } = window.location; + if (origin.includes('localhost')) return 'https://dev-datatrak.tupaia.org'; + return origin.replace('admin', 'datatrak'); }; - const renderButtons = useCallback(() => { - switch (resubmitStatus) { - case MODAL_STATUS.LOADING: - return <>; - case MODAL_STATUS.ERROR: - return ( - <> - - - ); - case MODAL_STATUS.SUCCESS: - return ( - <> - - - ); - case MODAL_STATUS.INITIAL: - default: - return ( - <> - - - - ); + const resubmitResponseAndRedirect = async () => { + // If the response has been changed, resubmit it before redirecting + if (!isUnchanged) { + await resubmitResponse(); + onAfterMutate(); } - }, [resubmitStatus, isFetching, isUnchanged]); + const { country_code: updatedCountryCode } = selectedEntity; + const { survey, primaryEntity } = data; + const countryCodeToUse = updatedCountryCode || primaryEntity.country_code; + const datatrakBaseUrl = getDatatrakBaseUrl(); + const url = `${datatrakBaseUrl}/survey/${countryCodeToUse}/${survey.code}/resubmit/${surveyResponseId}`; + // Open the URL in a new tab, so the user can resubmit the response in Datatrak + window.open(url, '_blank'); + }; - const existingAndNewFields = { ...data?.surveyResponse, ...surveyResubmission }; - const isResubmitting = resubmitStatus === MODAL_STATUS.LOADING; - const isResubmitSuccess = resubmitStatus === MODAL_STATUS.SUCCESS; + const renderButtons = useCallback(() => { + if (isLoading) return null; + if (isError) + return ( + + ); + if (isSuccess) return ; + return ( + + +
+ + +
+
+ ); + }, [isFetching, isUnchanged, isLoading, isError, isSuccess]); return ( <> - {!isFetching && !isResubmitSuccess && ( - <> - - setSurveyResubmission({ ...surveyResubmission, [field]: updatedField }) - } - setSelectedEntity={setSelectedEntity} - /> - - - setSurveyResubmission({ ...surveyResubmission, [field]: updatedField }) - } - onSetFormFile={onSetFormFile} - survey={data?.survey} - existingAnswers={data?.answers} - selectedEntity={selectedEntity} - fields={existingAndNewFields} - /> - + {!isFetching && !isSuccess && ( + + setSurveyResubmission({ ...surveyResubmission, [field]: updatedField }) + } + setSelectedEntity={setSelectedEntity} + /> )} - {isResubmitSuccess && 'The survey response has been successfully submitted.'} + {isSuccess && 'The survey response has been successfully submitted.'} {renderButtons()} diff --git a/packages/admin-panel/src/surveyResponse/ResponseFields.jsx b/packages/admin-panel/src/surveyResponse/ResponseFields.jsx index e1170a08db..bbc6c9ffe6 100644 --- a/packages/admin-panel/src/surveyResponse/ResponseFields.jsx +++ b/packages/admin-panel/src/surveyResponse/ResponseFields.jsx @@ -3,12 +3,9 @@ * Copyright (c) 2023 Beyond Essential Systems Pty Ltd */ -/* eslint-disable camelcase */ - import React, { useState } from 'react'; import styled from 'styled-components'; import PropTypes from 'prop-types'; -import Paper from '@material-ui/core/Paper'; import { Typography } from '@material-ui/core'; import { Select, DateTimePicker } from '@tupaia/ui-components'; import { ApprovalStatus } from '@tupaia/types'; @@ -17,26 +14,52 @@ import { Autocomplete } from '../autocomplete'; import { useDebounce } from '../utilities'; import { useEntities } from '../VizBuilderApp/api'; -const SectionWrapper = styled.div` - display: grid; - grid-template-columns: 1fr 1fr 1fr; - column-gap: 10px; +const InputSection = styled.div` + margin-block-start: 1.25rem; + margin-block-end: 1.2rem; +`; + +const BorderedSection = styled.div` + border: 1px solid ${props => props.theme.palette.grey['400']}; + border-radius: 4px; + padding: 1.1rem; +`; + +const Row = styled.div` + display: flex; + justify-content: space-between; + > div { + width: 49%; + } + .MuiFormControl-root { + margin-block-end: 0; + } +`; + +const ResponseFieldHeading = styled(Typography).attrs({ + variant: 'h3', +})` + padding-block-end: 0.2rem; + font-size: ${props => props.theme.typography.body2.fontSize}; + font-weight: ${props => props.theme.typography.fontWeightRegular}; + color: ${props => props.theme.palette.text.secondary}; `; -const ResponseFieldHeading = styled(Typography)` - font-weight: 500; - padding-bottom: 0.5rem; +const ResponseFieldValue = styled(Typography)` + font-weight: ${props => props.theme.typography.fontWeightMedium}; `; const ResponseFieldWrapper = styled.div` - padding: 1rem 0; + + ${Row} { + margin-block-start: 1.25rem; + } `; const ResponseField = ({ title, value }) => { return ( - {title} - {value} + {title} + {value} ); }; @@ -65,14 +88,18 @@ export const ResponseFields = ({ const limitedLocations = entities.slice(0, 20); return ( - - + <> + - - + + + + + + - { - onChange('data_time', format(AESTDate, 'yyyy-MM-dd HH:mm:ss')); - }} - /> - - { + onChange('approval_status', event.target.value); + }} + value={fields.approval_status} + /> + + + ); }; diff --git a/packages/admin-panel/src/surveyResponse/ResubmitSurveyResponseModal.jsx b/packages/admin-panel/src/surveyResponse/ResubmitSurveyResponseModal.jsx index 2bfcb87b69..bd03253ade 100644 --- a/packages/admin-panel/src/surveyResponse/ResubmitSurveyResponseModal.jsx +++ b/packages/admin-panel/src/surveyResponse/ResubmitSurveyResponseModal.jsx @@ -18,8 +18,8 @@ export const ResubmitSurveyResponseModalComponent = ({ onAfterMutate, }) => { return ( - - + + onDismiss()} diff --git a/packages/datatrak-web/src/features/Survey/SurveyContext/SurveyContext.tsx b/packages/datatrak-web/src/features/Survey/SurveyContext/SurveyContext.tsx index 51cb302588..9d40f61f36 100644 --- a/packages/datatrak-web/src/features/Survey/SurveyContext/SurveyContext.tsx +++ b/packages/datatrak-web/src/features/Survey/SurveyContext/SurveyContext.tsx @@ -123,10 +123,12 @@ export const useSurveyForm = () => { const isLast = screenNumber === numberOfScreens; const isSuccessScreen = !!useMatch(ROUTES.SURVEY_SUCCESS); const isReviewScreen = !!useMatch(ROUTES.SURVEY_REVIEW); - const isResubmit = !!useMatch(ROUTES.SURVEY_RESUBMIT); + const isResponseScreen = !!useMatch(ROUTES.SURVEY_RESPONSE); const isResubmitScreen = !!useMatch(ROUTES.SURVEY_RESUBMIT_SCREEN); const isResubmitReviewScreen = !!useMatch(ROUTES.SURVEY_RESUBMIT_REVIEW); + const isResubmit = + !!useMatch(ROUTES.SURVEY_RESUBMIT) || isResubmitScreen || isResubmitReviewScreen; const toggleSideMenu = () => { dispatch({ type: ACTION_TYPES.TOGGLE_SIDE_MENU }); diff --git a/packages/datatrak-web/src/routes/SurveyRoutes.tsx b/packages/datatrak-web/src/routes/SurveyRoutes.tsx index dae4f873af..a6b3a86041 100644 --- a/packages/datatrak-web/src/routes/SurveyRoutes.tsx +++ b/packages/datatrak-web/src/routes/SurveyRoutes.tsx @@ -20,19 +20,19 @@ import { useCurrentUserContext, useSurvey } from '../api'; import { SurveyResponseRoute } from './SurveyResponseRoute'; // Redirect to the start of the survey if no screen number is provided -const SurveyStartRedirect = () => { +const SurveyStartRedirect = ({ baseRoute = ROUTES.SURVEY_SCREEN }) => { const params = useParams(); - const path = generatePath(ROUTES.SURVEY_SCREEN, { ...params, screenNumber: '1' }); + const path = generatePath(baseRoute, { ...params, screenNumber: '1' }); return ; }; // Redirect to the start of the survey if they try to access a screen that is not visible on the survey -const SurveyPageRedirect = ({ children }) => { +const SurveyPageRedirect = ({ children, baseRoute = ROUTES.SURVEY_SCREEN }) => { const { screenNumber } = useParams(); const { visibleScreens } = useSurveyForm(); if (visibleScreens && visibleScreens.length && visibleScreens.length < Number(screenNumber)) { - return ; + return ; } return children; }; @@ -63,6 +63,19 @@ const SurveyRoute = ({ children }) => { return children; }; +const SurveyResubmitRedirect = () => { + const params = useParams(); + return ( + + ); +}; + export const SurveyRoutes = ( }> } /> - } /> + } /> + + + + } + /> } /> diff --git a/packages/datatrak-web/src/views/SurveyPage.tsx b/packages/datatrak-web/src/views/SurveyPage.tsx index ec28f86a75..2c805c8c27 100644 --- a/packages/datatrak-web/src/views/SurveyPage.tsx +++ b/packages/datatrak-web/src/views/SurveyPage.tsx @@ -47,30 +47,17 @@ const SurveyScreenContainer = styled.div<{ `; const SurveyPageInner = () => { - const { screenNumber } = useParams(); - const { formData, isSuccessScreen, isResponseScreen, cancelModalOpen, closeCancelConfirmation } = - useSurveyForm(); + const { screenNumber, countryCode, surveyCode } = useParams(); + const { + formData, + isSuccessScreen, + isResponseScreen, + cancelModalOpen, + closeCancelConfirmation, + isResubmit, + } = useSurveyForm(); const resolver = useValidationResolver(); const formContext = useForm({ defaultValues: formData, reValidateMode: 'onSubmit', resolver }); - - return ( - - - - - {/* Use a key to render a different survey screen component for every screen number. This is so - that the screen can be easily initialised with the form data. See https://react.dev/learn/you-might-not-need-an-effect#resetting-all-state-when-a-prop-changes */} - - - - - - ); -}; - -// The form provider has to be outside the outlet so that the form context is available to all. This is also so that the side menu can be outside of the 'SurveyLayout' page, because otherwise it rerenders on survey screen change, which makes it close and open again every time you change screen via the jump-to menu. The survey side menu needs to be inside the form provider so that it can access the form context to save form data -export const SurveyPage = () => { - const { countryCode, surveyCode } = useParams(); const { mutateAsync: editUser } = useEditUser(); const user = useCurrentUserContext(); const { data: survey } = useSurvey(surveyCode); @@ -78,7 +65,7 @@ export const SurveyPage = () => { // Update the user's preferred country if they start a survey in a different country useEffect(() => { - if (!surveyCountry?.code || !user.isLoggedIn) { + if (!surveyCountry?.code || !user.isLoggedIn || isResubmit) { return; } if (user.country?.code !== countryCode) { @@ -93,7 +80,7 @@ export const SurveyPage = () => { // Update the user's preferred project if they start a survey in a different project to the saved project useEffect(() => { - if (!survey?.projectId || !user.isLoggedIn) { + if (!survey?.projectId || !user.isLoggedIn || isResubmit) { return; } const { projectId } = survey; @@ -107,6 +94,23 @@ export const SurveyPage = () => { } }, [survey?.id]); + return ( + + + + + {/* Use a key to render a different survey screen component for every screen number. This is so + that the screen can be easily initialised with the form data. See https://react.dev/learn/you-might-not-need-an-effect#resetting-all-state-when-a-prop-changes */} + + + + + + ); +}; + +// The form provider has to be outside the outlet so that the form context is available to all. This is also so that the side menu can be outside of the 'SurveyLayout' page, because otherwise it rerenders on survey screen change, which makes it close and open again every time you change screen via the jump-to menu. The survey side menu needs to be inside the form provider so that it can access the form context to save form data +export const SurveyPage = () => { return ( diff --git a/packages/ui-components/src/components/Inputs/TextField.tsx b/packages/ui-components/src/components/Inputs/TextField.tsx index 6768ce734e..dedf0d04af 100644 --- a/packages/ui-components/src/components/Inputs/TextField.tsx +++ b/packages/ui-components/src/components/Inputs/TextField.tsx @@ -23,7 +23,6 @@ const StyledTextField = styled(MuiTextField)` .MuiInputBase-input { color: ${props => props.theme.palette.text.primary}; font-weight: 400; - line-height: 1.2rem; border-radius: 3px; } From 732f1f5783de644191aa9ceeeefc745dd0bcdd01 Mon Sep 17 00:00:00 2001 From: alexd-bes <129009580+alexd-bes@users.noreply.github.com> Date: Thu, 23 May 2024 09:42:20 +1200 Subject: [PATCH 15/25] Build fixes --- .../src/api/mutations/useResubmitSurveyResponse.ts | 2 +- .../datatrak-web/src/features/Survey/SurveyLayout.tsx | 11 ++++++----- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/packages/datatrak-web/src/api/mutations/useResubmitSurveyResponse.ts b/packages/datatrak-web/src/api/mutations/useResubmitSurveyResponse.ts index 9ac4523a4d..9d8df82232 100644 --- a/packages/datatrak-web/src/api/mutations/useResubmitSurveyResponse.ts +++ b/packages/datatrak-web/src/api/mutations/useResubmitSurveyResponse.ts @@ -11,7 +11,7 @@ import { post } from '../api'; import { getAllSurveyComponents, useSurveyForm } from '../../features'; import { SurveyScreenComponent } from '../../types'; import { ROUTES } from '../../constants'; -import { AnswersT, isFileUploadAnswer } from './useSubmitSurvey'; +import { AnswersT, isFileUploadAnswer } from './useSubmitSurveyResponse'; const processAnswers = ( answers: AnswersT, diff --git a/packages/datatrak-web/src/features/Survey/SurveyLayout.tsx b/packages/datatrak-web/src/features/Survey/SurveyLayout.tsx index f81cd2fe89..d4af35cb97 100644 --- a/packages/datatrak-web/src/features/Survey/SurveyLayout.tsx +++ b/packages/datatrak-web/src/features/Survey/SurveyLayout.tsx @@ -10,7 +10,7 @@ import styled from 'styled-components'; import { Paper as MuiPaper } from '@material-ui/core'; import { SpinningLoader } from '@tupaia/ui-components'; import { ROUTES } from '../../constants'; -import { useResubmitSurveyResponse, useSubmitSurvey } from '../../api/mutations'; +import { useResubmitSurveyResponse, useSubmitSurveyResponse } from '../../api/mutations'; import { SurveyParams } from '../../types'; import { useSurveyForm } from './SurveyContext'; import { SIDE_MENU_WIDTH, SurveySideMenu } from './Components'; @@ -86,8 +86,9 @@ export const SurveyLayout = () => { isResubmitReviewScreen, } = useSurveyForm(); const { handleSubmit, getValues } = useFormContext(); - const { mutate: submitSurvey, isLoading: isSubmittingSurvey } = useSubmitSurvey(); - const { mutate: resubmitSurveyResponse, isLoading: isResubmittingSurvey } = + const { mutate: submitSurveyResponse, isLoading: isSubmittingSurveyResponse } = + useSubmitSurveyResponse(); + const { mutate: resubmitSurveyResponse, isLoading: isResubmittingSurveyResponse } = useResubmitSurveyResponse(); const { back, next } = useSurveyRouting(numberOfScreens); @@ -132,14 +133,14 @@ export const SurveyLayout = () => { }; const onSubmit = data => { - const submitAction = isResubmitReviewScreen ? resubmitSurveyResponse : submitSurvey; + const submitAction = isResubmitReviewScreen ? resubmitSurveyResponse : submitSurveyResponse; if (isReviewScreen || isResubmitReviewScreen) return submitAction(data); return navigateNext(data); }; const handleClickSubmit = handleSubmit(onSubmit, onError); - const showLoader = isSubmittingSurvey || isResubmittingSurvey; + const showLoader = isSubmittingSurveyResponse || isResubmittingSurveyResponse; return ( <> From 73e1de7fc5e977beddf3f7de7cac94b14fbcef5a Mon Sep 17 00:00:00 2001 From: alexd-bes <129009580+alexd-bes@users.noreply.github.com> Date: Tue, 11 Jun 2024 09:43:06 +1200 Subject: [PATCH 16/25] Apply primary entity answer to resubmit --- .../src/routes/SingleSurveyResponseRoute.ts | 1 + .../mutations/useResubmitSurveyResponse.ts | 20 ++++++++++++++++--- .../src/api/queries/useSurveyResponse.ts | 16 ++++++++++++--- 3 files changed, 31 insertions(+), 6 deletions(-) diff --git a/packages/datatrak-web-server/src/routes/SingleSurveyResponseRoute.ts b/packages/datatrak-web-server/src/routes/SingleSurveyResponseRoute.ts index 93917f89de..5834ab7e27 100644 --- a/packages/datatrak-web-server/src/routes/SingleSurveyResponseRoute.ts +++ b/packages/datatrak-web-server/src/routes/SingleSurveyResponseRoute.ts @@ -25,6 +25,7 @@ const DEFAULT_FIELDS = [ 'country.name', 'data_time', 'entity.name', + 'entity.id', 'id', 'survey.name', 'survey.code', diff --git a/packages/datatrak-web/src/api/mutations/useResubmitSurveyResponse.ts b/packages/datatrak-web/src/api/mutations/useResubmitSurveyResponse.ts index 9d8df82232..b105bae932 100644 --- a/packages/datatrak-web/src/api/mutations/useResubmitSurveyResponse.ts +++ b/packages/datatrak-web/src/api/mutations/useResubmitSurveyResponse.ts @@ -5,7 +5,7 @@ import { useMutation } from 'react-query'; import { generatePath, useNavigate, useParams } from 'react-router'; -import { QuestionType } from '@tupaia/types'; +import { Answer, QuestionType } from '@tupaia/types'; import { getUniqueSurveyQuestionFileName } from '@tupaia/utils'; import { post } from '../api'; import { getAllSurveyComponents, useSurveyForm } from '../../features'; @@ -18,10 +18,16 @@ const processAnswers = ( questionsById: Record, ) => { const files: File[] = []; + let entityId = null as string | null; const formattedAnswers = Object.entries(answers).reduce((acc, [questionId, answer]) => { const { code, type } = questionsById[questionId]; if (!code) return acc; + if (type === QuestionType.PrimaryEntity && answer) { + entityId = answer as string; + return acc; + } + if (type === QuestionType.File && isFileUploadAnswer(answer) && answer.value instanceof File) { // Create a new file with a unique name, and add it to the files array, so we can add to the FormData, as this is what the central server expects const uniqueFileName = getUniqueSurveyQuestionFileName(answer.name); @@ -44,6 +50,7 @@ const processAnswers = ( return { answers: formattedAnswers, files, + entityId, }; }; @@ -64,10 +71,17 @@ export const useResubmitSurveyResponse = () => { if (!surveyAnswers) { return; } - const { answers, files } = processAnswers(surveyAnswers, questionsById); + const { answers, files, entityId } = processAnswers(surveyAnswers, questionsById); const formData = new FormData(); - formData.append('payload', JSON.stringify({ answers })); + const formDataToSubmit = { answers } as { + answers: Record; + entity_id?: string; + }; + if (entityId) { + formDataToSubmit.entity_id = entityId; + } + formData.append('payload', JSON.stringify(formDataToSubmit)); files.forEach(file => { formData.append(file.name, file); }); diff --git a/packages/datatrak-web/src/api/queries/useSurveyResponse.ts b/packages/datatrak-web/src/api/queries/useSurveyResponse.ts index 17a935ef5e..948782b238 100644 --- a/packages/datatrak-web/src/api/queries/useSurveyResponse.ts +++ b/packages/datatrak-web/src/api/queries/useSurveyResponse.ts @@ -4,16 +4,18 @@ */ import { useQuery } from 'react-query'; import { useNavigate } from 'react-router'; -import { DatatrakWebSingleSurveyResponseRequest } from '@tupaia/types'; +import { DatatrakWebSingleSurveyResponseRequest, QuestionType } from '@tupaia/types'; import { get } from '../api'; import { ROUTES } from '../../constants'; import { errorToast } from '../../utils'; -import { useSurveyForm } from '../../features'; +import { getAllSurveyComponents, useSurveyForm } from '../../features'; export const useSurveyResponse = (surveyResponseId?: string) => { - const { setFormData } = useSurveyForm(); + const { setFormData, surveyScreens } = useSurveyForm(); const navigate = useNavigate(); + const flattenedScreenComponents = getAllSurveyComponents(surveyScreens); + return useQuery( ['surveyResponse', surveyResponseId], (): Promise => @@ -29,12 +31,20 @@ export const useSurveyResponse = (surveyResponseId?: string) => { errorToast(error.message); }, onSuccess: data => { + const primaryEntityQuestion = flattenedScreenComponents.find( + component => component.type === QuestionType.PrimaryEntity, + ); // handle updating answers here - if this is done in the component, the answers get reset on every re-render const formattedAnswers = Object.entries(data.answers).reduce((acc, [key, value]) => { // If the value is a stringified object, parse it const isStringifiedObject = typeof value === 'string' && value.startsWith('{'); return { ...acc, [key]: isStringifiedObject ? JSON.parse(value) : value }; }, {}); + + if (primaryEntityQuestion && data.entityId) { + formattedAnswers[primaryEntityQuestion.questionId] = data.entityId; + } + setFormData(formattedAnswers); }, }, From 6089c639d6d925d06045e8c7439917fd64367cec Mon Sep 17 00:00:00 2001 From: alexd-bes <129009580+alexd-bes@users.noreply.github.com> Date: Tue, 11 Jun 2024 10:15:35 +1200 Subject: [PATCH 17/25] Fix breaking data_time questions --- .../src/api/mutations/useResubmitSurveyResponse.ts | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/packages/datatrak-web/src/api/mutations/useResubmitSurveyResponse.ts b/packages/datatrak-web/src/api/mutations/useResubmitSurveyResponse.ts index b105bae932..3ec6863d4c 100644 --- a/packages/datatrak-web/src/api/mutations/useResubmitSurveyResponse.ts +++ b/packages/datatrak-web/src/api/mutations/useResubmitSurveyResponse.ts @@ -19,6 +19,7 @@ const processAnswers = ( ) => { const files: File[] = []; let entityId = null as string | null; + let dataTime = null as string | null; const formattedAnswers = Object.entries(answers).reduce((acc, [questionId, answer]) => { const { code, type } = questionsById[questionId]; if (!code) return acc; @@ -41,6 +42,12 @@ const processAnswers = ( [code]: uniqueFileName, }; } + + if (type === QuestionType.DateOfData || type === QuestionType.SubmissionDate) { + const date = new Date(answer as string); + dataTime = date.toISOString(); + return acc; + } return { ...acc, [code]: answer, @@ -51,6 +58,7 @@ const processAnswers = ( answers: formattedAnswers, files, entityId, + dataTime, }; }; @@ -71,16 +79,20 @@ export const useResubmitSurveyResponse = () => { if (!surveyAnswers) { return; } - const { answers, files, entityId } = processAnswers(surveyAnswers, questionsById); + const { answers, files, entityId, dataTime } = processAnswers(surveyAnswers, questionsById); const formData = new FormData(); const formDataToSubmit = { answers } as { answers: Record; entity_id?: string; + data_time?: string; }; if (entityId) { formDataToSubmit.entity_id = entityId; } + if (dataTime) { + formDataToSubmit.data_time = dataTime; + } formData.append('payload', JSON.stringify(formDataToSubmit)); files.forEach(file => { formData.append(file.name, file); From a092239428570e791e7e2eef1fe0cb127810b387 Mon Sep 17 00:00:00 2001 From: alexd-bes <129009580+alexd-bes@users.noreply.github.com> Date: Tue, 11 Jun 2024 10:25:31 +1200 Subject: [PATCH 18/25] Fix build --- .../datatrak-web/src/api/mutations/useResubmitSurveyResponse.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/datatrak-web/src/api/mutations/useResubmitSurveyResponse.ts b/packages/datatrak-web/src/api/mutations/useResubmitSurveyResponse.ts index 3ec6863d4c..5d51e801c3 100644 --- a/packages/datatrak-web/src/api/mutations/useResubmitSurveyResponse.ts +++ b/packages/datatrak-web/src/api/mutations/useResubmitSurveyResponse.ts @@ -5,7 +5,7 @@ import { useMutation } from 'react-query'; import { generatePath, useNavigate, useParams } from 'react-router'; -import { Answer, QuestionType } from '@tupaia/types'; +import { QuestionType } from '@tupaia/types'; import { getUniqueSurveyQuestionFileName } from '@tupaia/utils'; import { post } from '../api'; import { getAllSurveyComponents, useSurveyForm } from '../../features'; From a826d93dbda14585fffa9cd3ea179e7d8143ed0d Mon Sep 17 00:00:00 2001 From: alexd-bes <129009580+alexd-bes@users.noreply.github.com> Date: Tue, 11 Jun 2024 10:30:39 +1200 Subject: [PATCH 19/25] Fix survey responses with file uploads --- .../src/api/mutations/useSubmitSurveyResponse.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/packages/datatrak-web/src/api/mutations/useSubmitSurveyResponse.ts b/packages/datatrak-web/src/api/mutations/useSubmitSurveyResponse.ts index 642cb4b4d4..8e3564b12b 100644 --- a/packages/datatrak-web/src/api/mutations/useSubmitSurveyResponse.ts +++ b/packages/datatrak-web/src/api/mutations/useSubmitSurveyResponse.ts @@ -73,6 +73,7 @@ const processAnswers = async (answers: AnswersT, questionsById) => { if (question.type === QuestionType.File && isFileUploadAnswer(answer)) { // convert to an object with an encoded file so that it can be handled in the backend and uploaded to s3 const encodedFile = await createEncodedFile(answer.value as File); + formattedAnswers[questionId] = { name: answer.name, value: encodedFile, @@ -92,12 +93,17 @@ export const useSubmitSurveyResponse = () => { const surveyResponseData = useSurveyResponseData(); + const questionsById = surveyResponseData.questions.reduce((acc, question) => { + acc[question.questionId] = question; + return acc; + }, {}); + return useMutation( async (answers: AnswersT) => { if (!answers) { return; } - const formattedAnswers = await processAnswers(answers, surveyResponseData.questions); + const formattedAnswers = await processAnswers(answers, questionsById); return post('submitSurveyResponse', { data: { ...surveyResponseData, answers: formattedAnswers }, From 8d8b169b2c5ae57cbe05ab8bd8fdc173d2bf7f28 Mon Sep 17 00:00:00 2001 From: alexd-bes <129009580+alexd-bes@users.noreply.github.com> Date: Tue, 11 Jun 2024 10:48:54 +1200 Subject: [PATCH 20/25] Display file name for saved file questions and fix remove file value --- .../mutations/useResubmitSurveyResponse.ts | 32 ++++++++++++------- .../src/api/queries/useSurveyResponse.ts | 7 ++++ .../src/features/Questions/FileQuestion.tsx | 1 + 3 files changed, 28 insertions(+), 12 deletions(-) diff --git a/packages/datatrak-web/src/api/mutations/useResubmitSurveyResponse.ts b/packages/datatrak-web/src/api/mutations/useResubmitSurveyResponse.ts index 5d51e801c3..76b0b71e43 100644 --- a/packages/datatrak-web/src/api/mutations/useResubmitSurveyResponse.ts +++ b/packages/datatrak-web/src/api/mutations/useResubmitSurveyResponse.ts @@ -29,18 +29,26 @@ const processAnswers = ( return acc; } - if (type === QuestionType.File && isFileUploadAnswer(answer) && answer.value instanceof File) { - // Create a new file with a unique name, and add it to the files array, so we can add to the FormData, as this is what the central server expects - const uniqueFileName = getUniqueSurveyQuestionFileName(answer.name); - files.push( - new File([answer.value as Blob], uniqueFileName, { - type: answer.value.type, - }), - ); - return { - ...acc, - [code]: uniqueFileName, - }; + if (type === QuestionType.File) { + if (isFileUploadAnswer(answer) && answer.value instanceof File) { + // Create a new file with a unique name, and add it to the files array, so we can add to the FormData, as this is what the central server expects + const uniqueFileName = getUniqueSurveyQuestionFileName(answer.name); + files.push( + new File([answer.value as Blob], uniqueFileName, { + type: answer.value.type, + }), + ); + return { + ...acc, + [code]: uniqueFileName, + }; + } + if (answer && typeof answer === 'object' && 'name' in answer) { + return { + ...acc, + [code]: answer.name, + }; + } } if (type === QuestionType.DateOfData || type === QuestionType.SubmissionDate) { diff --git a/packages/datatrak-web/src/api/queries/useSurveyResponse.ts b/packages/datatrak-web/src/api/queries/useSurveyResponse.ts index 948782b238..2673433c62 100644 --- a/packages/datatrak-web/src/api/queries/useSurveyResponse.ts +++ b/packages/datatrak-web/src/api/queries/useSurveyResponse.ts @@ -38,6 +38,13 @@ export const useSurveyResponse = (surveyResponseId?: string) => { const formattedAnswers = Object.entries(data.answers).reduce((acc, [key, value]) => { // If the value is a stringified object, parse it const isStringifiedObject = typeof value === 'string' && value.startsWith('{'); + const question = flattenedScreenComponents.find( + component => component.questionId === key, + ); + if (!question) return acc; + if (question.type === QuestionType.File && value) { + return { ...acc, [key]: { name: value, value: null } }; + } return { ...acc, [key]: isStringifiedObject ? JSON.parse(value) : value }; }, {}); diff --git a/packages/datatrak-web/src/features/Questions/FileQuestion.tsx b/packages/datatrak-web/src/features/Questions/FileQuestion.tsx index 8ad10a729d..54b25e4e7b 100644 --- a/packages/datatrak-web/src/features/Questions/FileQuestion.tsx +++ b/packages/datatrak-web/src/features/Questions/FileQuestion.tsx @@ -32,6 +32,7 @@ export const FileQuestion = ({ controllerProps: { onChange, value: selectedFile, name }, }: SurveyQuestionInputProps) => { const handleChange = (_e, _name, files) => { + if (!files || files.length === 0) return onChange(null); const file = files[0]; onChange({ name: file.name, From a2c5cef3702edaee2ce78e051e91eb3d6072df44 Mon Sep 17 00:00:00 2001 From: alexd-bes <129009580+alexd-bes@users.noreply.github.com> Date: Wed, 12 Jun 2024 08:06:05 +1200 Subject: [PATCH 21/25] Use dataTime for date questions --- .../datatrak-web/src/api/queries/useSurveyResponse.ts | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/packages/datatrak-web/src/api/queries/useSurveyResponse.ts b/packages/datatrak-web/src/api/queries/useSurveyResponse.ts index 2673433c62..79d1e3aed5 100644 --- a/packages/datatrak-web/src/api/queries/useSurveyResponse.ts +++ b/packages/datatrak-web/src/api/queries/useSurveyResponse.ts @@ -34,6 +34,12 @@ export const useSurveyResponse = (surveyResponseId?: string) => { const primaryEntityQuestion = flattenedScreenComponents.find( component => component.type === QuestionType.PrimaryEntity, ); + + const dateOfDataQuestion = flattenedScreenComponents.find( + component => + component.type === QuestionType.DateOfData || + component.type === QuestionType.SubmissionDate, + ); // handle updating answers here - if this is done in the component, the answers get reset on every re-render const formattedAnswers = Object.entries(data.answers).reduce((acc, [key, value]) => { // If the value is a stringified object, parse it @@ -45,6 +51,7 @@ export const useSurveyResponse = (surveyResponseId?: string) => { if (question.type === QuestionType.File && value) { return { ...acc, [key]: { name: value, value: null } }; } + return { ...acc, [key]: isStringifiedObject ? JSON.parse(value) : value }; }, {}); @@ -52,6 +59,10 @@ export const useSurveyResponse = (surveyResponseId?: string) => { formattedAnswers[primaryEntityQuestion.questionId] = data.entityId; } + if (dateOfDataQuestion && data.dataTime) { + formattedAnswers[dateOfDataQuestion.questionId] = data.dataTime; + } + setFormData(formattedAnswers); }, }, From c99d11f2c154f7e1b9dfa435fa875eef9602384e Mon Sep 17 00:00:00 2001 From: alexd-bes <129009580+alexd-bes@users.noreply.github.com> Date: Thu, 20 Jun 2024 16:21:39 +1200 Subject: [PATCH 22/25] fix permissions --- .../src/routes/SingleSurveyResponseRoute.ts | 1 + packages/datatrak-web/src/constants/url.ts | 8 +++++++- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/packages/datatrak-web-server/src/routes/SingleSurveyResponseRoute.ts b/packages/datatrak-web-server/src/routes/SingleSurveyResponseRoute.ts index 5834ab7e27..86e64cb4c7 100644 --- a/packages/datatrak-web-server/src/routes/SingleSurveyResponseRoute.ts +++ b/packages/datatrak-web-server/src/routes/SingleSurveyResponseRoute.ts @@ -65,6 +65,7 @@ export class SingleSurveyResponseRoute extends Route Date: Thu, 4 Jul 2024 15:21:32 +1200 Subject: [PATCH 23/25] Send timezone through with resubmission --- .../src/api/mutations/useResubmitSurveyResponse.ts | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/packages/datatrak-web/src/api/mutations/useResubmitSurveyResponse.ts b/packages/datatrak-web/src/api/mutations/useResubmitSurveyResponse.ts index 76b0b71e43..2a6e22d161 100644 --- a/packages/datatrak-web/src/api/mutations/useResubmitSurveyResponse.ts +++ b/packages/datatrak-web/src/api/mutations/useResubmitSurveyResponse.ts @@ -6,7 +6,7 @@ import { useMutation } from 'react-query'; import { generatePath, useNavigate, useParams } from 'react-router'; import { QuestionType } from '@tupaia/types'; -import { getUniqueSurveyQuestionFileName } from '@tupaia/utils'; +import { getBrowserTimeZone, getUniqueSurveyQuestionFileName } from '@tupaia/utils'; import { post } from '../api'; import { getAllSurveyComponents, useSurveyForm } from '../../features'; import { SurveyScreenComponent } from '../../types'; @@ -88,12 +88,13 @@ export const useResubmitSurveyResponse = () => { return; } const { answers, files, entityId, dataTime } = processAnswers(surveyAnswers, questionsById); - + const timezone = getBrowserTimeZone(); const formData = new FormData(); - const formDataToSubmit = { answers } as { + const formDataToSubmit = { answers, timezone } as { answers: Record; entity_id?: string; data_time?: string; + timezone?: string; }; if (entityId) { formDataToSubmit.entity_id = entityId; @@ -101,6 +102,7 @@ export const useResubmitSurveyResponse = () => { if (dataTime) { formDataToSubmit.data_time = dataTime; } + formData.append('payload', JSON.stringify(formDataToSubmit)); files.forEach(file => { formData.append(file.name, file); From 1366e629904d80dbf3b8379ad4989fa72dfb6655 Mon Sep 17 00:00:00 2001 From: alexd-bes <129009580+alexd-bes@users.noreply.github.com> Date: Tue, 9 Jul 2024 10:57:34 +1200 Subject: [PATCH 24/25] Open up permissions --- .../src/routes/SingleSurveyResponseRoute.ts | 38 ++++++++++++++++--- packages/datatrak-web-server/src/types.ts | 2 + 2 files changed, 34 insertions(+), 6 deletions(-) diff --git a/packages/datatrak-web-server/src/routes/SingleSurveyResponseRoute.ts b/packages/datatrak-web-server/src/routes/SingleSurveyResponseRoute.ts index 86e64cb4c7..3f0b348023 100644 --- a/packages/datatrak-web-server/src/routes/SingleSurveyResponseRoute.ts +++ b/packages/datatrak-web-server/src/routes/SingleSurveyResponseRoute.ts @@ -31,22 +31,39 @@ const DEFAULT_FIELDS = [ 'survey.code', 'user_id', 'country.code', + 'survey.permission_group_id', ]; const BES_ADMIN_PERMISSION_GROUP = 'BES Admin'; // If the user is not a BES admin or does not have access to the admin panel, they should not be able to view the survey response -const assertCanViewSurveyResponse = (accessPolicy: AccessPolicy, countryCode: string) => { +const assertCanViewSurveyResponse = ( + accessPolicy: AccessPolicy, + countryCode: string, + surveyPermissionGroupName: string, +) => { const isBESAdmin = accessPolicy.allowsSome(undefined, BES_ADMIN_PERMISSION_GROUP); - const hasAdminPanelAccess = accessPolicy.allows(countryCode, TUPAIA_ADMIN_PANEL_PERMISSION_GROUP); - if (!isBESAdmin && !hasAdminPanelAccess) { + if (isBESAdmin) { + return true; + } + + const hasAdminPanelAccess = accessPolicy.allowsSome( + undefined, + TUPAIA_ADMIN_PANEL_PERMISSION_GROUP, + ); + + const hasAccessToCountry = accessPolicy.allows(countryCode, surveyPermissionGroupName); + // The user must have access to the admin panel AND the country with the survey permission group + if (!hasAdminPanelAccess && !hasAccessToCountry) { throw new PermissionsError('You do not have access to view this survey response'); } + + return true; }; export class SingleSurveyResponseRoute extends Route { public async buildResponse() { - const { ctx, params, query, accessPolicy } = this.req; + const { ctx, params, query, accessPolicy, models } = this.req; const { id: responseId } = params; const { fields = DEFAULT_FIELDS } = query; @@ -63,11 +80,20 @@ export class SingleSurveyResponseRoute extends Route Date: Fri, 16 Aug 2024 06:01:46 +1000 Subject: [PATCH 25/25] feat(dataTrak): RN-1274: Keep 'outdated' historical survey responses when resubmitting (#5758) * RN-1274: change route to `surveyResponses/:id/resubmit` * RN-1274: Reworked ResubmitSurveyResponse route to create a new response and outdate the previous one * RN-1274: Added resubmitSurveyResponse to the CentralApi * RN-1274: Added ResubmitSurveyResponseRoute to datatrak-web-server * RN-1274: Reworked survey response resubmission in datatrak to use the new backend routes * Fix tests * Edit survey response metadata * Fix tests * Edit survey response metadata * Resubmit survey response with original data time and user ID * PR fixes * take 1 * feat(adminPanel): RN-1228: Link surveys to Datatrak Web (#5671) * Make links * Use projectId * Update user preferences if project id is in url * Add comment * Allow country codes to be fetched for surveys * Link directly to survey * Default to DL and alphabetise the country codes * Change tooltip text * Update copy * Hide button for surveys with no countries --------- Co-authored-by: Andrew * Fix dataTime timezone change * Allow file questions to be viewed and changed * feat(tupaiaWeb): RN-1367: Multiphotograph viz captions + restyle (#5769) * Add `label` property to view data * Preview display * Display max 3 * WIP carousel * WIP * Working thumbnails * Working carousel * WiP * Styling * Add comments * Update schemas.ts * Adjust height and alignment * Make images contained * Fix responsive issue --------- Co-authored-by: Andrew * Fix issue * Fix date of data * Add outdated column to survey responses in admin panel * Reset entity question values when filter questions change * fix(datatrakWeb): Fix country code selector in reports export * fix(adminPanel): RN-1375: update 'Add' project editor for consistency (#5816) update editor column for consistency * Handle existing file answers * Use existing entity id if present * Add pill styling for response status field * Handle file names * Change pill colours around * Handle survey response file names * Don't save file url in answer * Fix tests * Don't default dates on resubmit * Handle when photo answer is a url * Allow `null` default date for resubmission * Save previous metadata on tracked entity * Fix undefined models error * Update project.pbxproj * Hide survey resubmit button for outdated responses * tweak(tupaiaWeb): RN-1394: Update tool tip for visualisation export (#5824) Tool tip update * tweak(adminPanel): RN-1399: Update icon and color as per Figma layouts (#5825) Download Icon update * tweak(adminPanel): RN-1274: Remove outdated survey responses and associated answers from DHIS via sync queue (#5827) * Remove outdated survey responses and associated answers from dhis via sync queue * Add tests * Add answers back into queue when survey response is changed back to current * Handle answers for outdated->current tests * Fix tests * Revert change to filter * Ignore outdated surveys from exports * Code question should be code generator type * Fix tests * Fix timezone issues * Update processSurveyResponse.test.ts * Get all answers for survey response * Handle when photo includes a url * Fix tests * Fix crashing error * Concert jpeg to jpg * Keep existing survey response timezone * fix(tupaiaWeb): RN-1414: Fix dashboard item permission error (#5836) Update ReportPermissionsChecker.js * Timezones * fix(adminPanel): RN-1289: update the entity associated with a survey resubmission (#5817) * Initial update * test updates * Update importSurveyResponses.js * Update importSurveyResponses.js * Update importSurveyResponses.js * Update SurveyResponseUpdatePersistor.js * Delete ~$nonPeriodicUpdates.xlsx * test updates * review comments * review updates * addition of tests --------- Co-authored-by: Andrew * Convert data_time to timezone date on server * Fix tests * Make dates/times zoneless so that they appear the same to everyone * Fix tests * Fix timezone offsets * Handle timezones with DST * Fixes --------- Co-authored-by: alexd-bes <129009580+alexd-bes@users.noreply.github.com> Co-authored-by: Salman <114740396+hrazasalman@users.noreply.github.com> Co-authored-by: Andrew Co-authored-by: Tom Caiger --- ...eyResponse.js => useEditSurveyResponse.js} | 6 +- .../src/importExport/ExportButton.jsx | 2 +- .../src/routes/projects/projects.js | 33 ++- ...surveyResponses.js => surveyResponses.jsx} | 52 +++- .../admin-panel/src/routes/surveys/surveys.js | 25 ++ .../admin-panel/src/surveyResponse/Form.jsx | 25 +- .../table/columnTypes/ExternalLinkButton.jsx | 39 +++ .../ResubmitSurveyResponseButton.jsx | 8 +- .../src/table/columnTypes/columnFilters.jsx | 35 +++ .../generateConfigForColumnType.jsx | 3 + .../utilities/makeSubstitutionsInString.js | 2 +- .../api-client/src/connections/CentralApi.ts | 12 + .../src/connections/mocks/MockCentralApi.ts | 6 + .../exportResponsesToFile.js | 1 + .../SurveyResponseUpdatePersistor.js | 20 +- .../importSurveyResponses.js | 40 ++- .../ConfigValidator/EntityConfigValidator.js | 24 +- packages/central-server/src/apiV2/index.js | 4 +- .../surveyResponses/EditSurveyResponse.js | 25 ++ .../surveyResponses/ResubmitSurveyResponse.js | 107 ++++---- .../src/apiV2/surveyResponses/index.js | 1 + .../resubmission/handleResubmission.js | 84 ------ .../resubmission/validateResubmission.js | 67 ----- .../upsertEntitiesAndOptions.js | 20 +- .../validateSurveyResponses.js | 4 +- .../src/apiV2/surveys/GETSurveys.js | 27 +- .../src/dataAccessors/upsertAnswers.js | 15 +- .../src/dhis/DhisChangeValidator.js | 157 ++++++++++- .../exportSurveyResponses.test.js | 11 + .../EntityConfigCellBuilder.test.js | 8 +- .../testFunctionality.js | 9 +- .../importSurveyResponses/testGeneral.js | 9 +- .../testOutdatedStatusUpdate.js | 4 +- .../importSurveyResponses/testPermissions.js | 23 +- .../importSurveyResponses/testValidation.js | 18 +- .../apiV2/resubmitSurveyResponse.test.js | 129 ++------- .../src/tests/apiV2/surveyResponse.test.js | 2 +- .../tests/dhis/DhisChangeValidator.test.js | 215 +++++++++++++++ .../functionality/nonPeriodicBaseline.xlsx | Bin 11818 -> 12310 bytes .../functionality/nonPeriodicUpdates.xlsx | Bin 12354 -> 12836 bytes .../functionality/periodicBaseline.xlsx | Bin 15009 -> 15345 bytes .../functionality/periodicUpdates.xlsx | Bin 15464 -> 15778 bytes .../validation/invalidEntity.xlsx | Bin 0 -> 9585 bytes .../validation/mismatchEntityNameAndCode.xlsx | Bin 0 -> 9598 bytes .../buildAndInsertSurveyResponses.js | 4 +- packages/datatrak-web-server/package.json | 2 + .../__tests__/processSurveyResponse.test.ts | 106 +++++++- .../datatrak-web-server/src/app/createApp.ts | 6 + .../src/routes/SingleSurveyResponseRoute.ts | 4 +- .../ResubmitSurveyResponseRoute.ts | 42 +++ .../src/routes/SubmitSurveyReponse/index.ts | 4 + .../processSurveyResponse.ts | 49 +++- .../datatrak-web-server/src/routes/index.ts | 7 +- .../src/__tests__/mocks/mockData/survey.json | 18 +- .../mutations/useResubmitSurveyResponse.ts | 116 ++------ .../api/mutations/useSubmitSurveyResponse.ts | 58 +--- .../src/api/queries/useSurveyResponse.ts | 16 +- .../src/features/Questions/FileQuestion.tsx | 29 +- .../Reports/Inputs/EntitySelectorInput.tsx | 5 +- .../Survey/Components/SurveyQuestion.tsx | 4 +- .../Screens/SurveyResubmitSuccessScreen.tsx | 4 +- .../features/Survey/SurveyContext/utils.ts | 61 ++++- .../SurveySelectPage/SurveySelectPage.tsx | 39 ++- .../meditrak-app/android/app/build.gradle | 4 +- packages/meditrak-app/android/build.gradle | 6 +- .../TupaiaMediTrak.xcodeproj/project.pbxproj | 8 +- packages/meditrak-app/package.json | 2 +- packages/server-utils/src/s3/S3Client.ts | 4 +- packages/tupaia-web/package.json | 2 + .../ExportDashboard/SelectVisualisations.tsx | 2 +- .../EnlargedDashboardItem.tsx | 32 ++- .../Visuals/View/MultiPhotograph/Carousel.tsx | 105 -------- .../MultiPhotographEnlarged.tsx | 255 ++++++++++++++++-- .../MultiPhotographPreview.tsx | 49 +++- packages/types/src/schemas/schemas.ts | 9 + .../types/src/types/models-extra/report.ts | 1 + .../ResubmitSurveyResponseRequest.ts | 16 ++ .../SingleSurveyResponseRequest.ts | 3 +- .../SubmitSurveyResponseRequest.ts | 1 + .../requests/datatrak-web-server/index.ts | 1 + packages/types/src/types/requests/index.ts | 1 + .../components/FilterableTable/FilterCell.tsx | 2 +- .../src/components/Inputs/FileUploadField.tsx | 11 +- packages/utils/package.json | 2 + packages/utils/src/__tests__/timezone.test.js | 23 ++ packages/utils/src/index.js | 1 + packages/utils/src/timezone.js | 31 +++ .../permissions/ReportPermissionsChecker.js | 46 +++- yarn.lock | 63 +++++ 89 files changed, 1722 insertions(+), 804 deletions(-) rename packages/admin-panel/src/api/mutations/{useResubmitSurveyResponse.js => useEditSurveyResponse.js} (69%) rename packages/admin-panel/src/routes/surveys/{surveyResponses.js => surveyResponses.jsx} (77%) create mode 100644 packages/admin-panel/src/table/columnTypes/ExternalLinkButton.jsx create mode 100644 packages/central-server/src/apiV2/surveyResponses/EditSurveyResponse.js delete mode 100644 packages/central-server/src/apiV2/surveyResponses/resubmission/handleResubmission.js delete mode 100644 packages/central-server/src/apiV2/surveyResponses/resubmission/validateResubmission.js create mode 100644 packages/central-server/src/tests/dhis/DhisChangeValidator.test.js create mode 100644 packages/central-server/src/tests/testData/surveyResponses/validation/invalidEntity.xlsx create mode 100644 packages/central-server/src/tests/testData/surveyResponses/validation/mismatchEntityNameAndCode.xlsx create mode 100644 packages/datatrak-web-server/src/routes/SubmitSurveyReponse/ResubmitSurveyResponseRoute.ts delete mode 100644 packages/tupaia-web/src/features/Visuals/View/MultiPhotograph/Carousel.tsx create mode 100644 packages/types/src/types/requests/datatrak-web-server/ResubmitSurveyResponseRequest.ts create mode 100644 packages/utils/src/__tests__/timezone.test.js create mode 100644 packages/utils/src/timezone.js diff --git a/packages/admin-panel/src/api/mutations/useResubmitSurveyResponse.js b/packages/admin-panel/src/api/mutations/useEditSurveyResponse.js similarity index 69% rename from packages/admin-panel/src/api/mutations/useResubmitSurveyResponse.js rename to packages/admin-panel/src/api/mutations/useEditSurveyResponse.js index b42418f53c..e542311339 100644 --- a/packages/admin-panel/src/api/mutations/useResubmitSurveyResponse.js +++ b/packages/admin-panel/src/api/mutations/useEditSurveyResponse.js @@ -6,13 +6,13 @@ import { useMutation, useQueryClient } from 'react-query'; import { useApiContext } from '../../utilities/ApiProvider'; -export const useResubmitSurveyResponse = (surveyResponseId, updatedSurveyResponse) => { +export const useEditSurveyResponse = (surveyResponseId, updatedSurveyResponse) => { const queryClient = useQueryClient(); const api = useApiContext(); return useMutation( - [`surveyResubmit`, surveyResponseId, updatedSurveyResponse], + [`surveyResponseEdit`, surveyResponseId, updatedSurveyResponse], () => { - return api.post(`surveyResponse/${surveyResponseId}/resubmit`, null, updatedSurveyResponse); + return api.put(`surveyResponses/${surveyResponseId}`, null, updatedSurveyResponse); }, { throwOnError: true, diff --git a/packages/admin-panel/src/importExport/ExportButton.jsx b/packages/admin-panel/src/importExport/ExportButton.jsx index b2529d66d0..37d9d0d5fe 100644 --- a/packages/admin-panel/src/importExport/ExportButton.jsx +++ b/packages/admin-panel/src/importExport/ExportButton.jsx @@ -5,7 +5,7 @@ import React from 'react'; import PropTypes from 'prop-types'; -import ExportIcon from '@material-ui/icons/GetApp'; +import { ExportIcon } from '../icons'; import { makeSubstitutionsInString } from '../utilities'; import { useApiContext } from '../utilities/ApiProvider'; import { ColumnActionButton } from '../table/columnTypes/ColumnActionButton'; diff --git a/packages/admin-panel/src/routes/projects/projects.js b/packages/admin-panel/src/routes/projects/projects.js index 84a79cb96f..1f61748358 100644 --- a/packages/admin-panel/src/routes/projects/projects.js +++ b/packages/admin-panel/src/routes/projects/projects.js @@ -78,20 +78,6 @@ const DEFAULT_FIELDS = [ maxWidth: 480, }, }, - { - Header: 'Config', - source: 'config', - type: 'jsonTooltip', - editConfig: { - type: 'jsonEditor', - labelTooltip: 'eg. { "tileSets": "osm,satellite,terrain", "permanentRegionLabels": true }', - }, - }, - { - Header: 'Sort', - source: 'sort_order', - width: 80, - }, ]; const CREATE_FIELDS = [ @@ -101,6 +87,11 @@ const CREATE_FIELDS = [ required: true, }, ...DEFAULT_FIELDS, + { + Header: 'Sort', + source: 'sort_order', + width: 80, + }, ]; const EDIT_FIELDS = [ @@ -111,6 +102,20 @@ const EDIT_FIELDS = [ required: true, }, ...DEFAULT_FIELDS, + { + Header: 'Config', + source: 'config', + type: 'jsonTooltip', + editConfig: { + type: 'jsonEditor', + labelTooltip: 'eg. { "tileSets": "osm,satellite,terrain", "permanentRegionLabels": true }', + }, + }, + { + Header: 'Sort', + source: 'sort_order', + width: 80, + }, ]; const NEW_PROJECT_COLUMNS = [ diff --git a/packages/admin-panel/src/routes/surveys/surveyResponses.js b/packages/admin-panel/src/routes/surveys/surveyResponses.jsx similarity index 77% rename from packages/admin-panel/src/routes/surveys/surveyResponses.js rename to packages/admin-panel/src/routes/surveys/surveyResponses.jsx index 7399838e89..31ef7824ed 100644 --- a/packages/admin-panel/src/routes/surveys/surveyResponses.js +++ b/packages/admin-panel/src/routes/surveys/surveyResponses.jsx @@ -2,21 +2,45 @@ * Tupaia * Copyright (c) 2017 - 2024 Beyond Essential Systems Pty Ltd */ - +import React from 'react'; import { getBrowserTimeZone } from '@tupaia/utils'; import moment from 'moment'; -import { ApprovalStatus } from '@tupaia/types'; +import PropTypes from 'prop-types'; +import styled from 'styled-components'; import { SurveyResponsesExportModal } from '../../importExport'; import { getPluralForm } from '../../pages/resources/resourceName'; +import { OutdatedFilter } from '../../table/columnTypes/columnFilters'; const RESOURCE_NAME = { singular: 'survey response' }; -// Don't include not_required as an editable option because it can lead to -// mis-matches between surveys and survey responses -export const APPROVAL_STATUS_TYPES = Object.values(ApprovalStatus).map(type => ({ - label: type, - value: type, -})); +const GREEN = '#47CA80'; +const GREY = '#898989'; + +const Pill = styled.span` + background-color: ${({ $color }) => { + return `${$color}33`; // slightly transparent + }}; + border-radius: 1.5rem; + padding: 0.3rem 0.9rem; + color: ${({ $color }) => $color}; + .cell-content:has(&) > div { + overflow: visible; + } +`; + +const ResponseStatusPill = ({ value }) => { + const text = value ? 'Outdated' : 'Current'; + const color = value ? GREY : GREEN; + return {text}; +}; + +ResponseStatusPill.propTypes = { + value: PropTypes.bool, +}; + +ResponseStatusPill.defaultProps = { + value: false, +}; const surveyName = { Header: 'Survey', @@ -56,9 +80,13 @@ const dateOfData = { }, }; -const approvalStatus = { - Header: 'Approval status', - source: 'approval_status', +const responseStatus = { + Header: 'Response status', + source: 'outdated', + Filter: OutdatedFilter, + width: 180, + // eslint-disable-next-line react/prop-types + Cell: ({ value }) => , }; const entityName = { @@ -80,7 +108,7 @@ export const SURVEY_RESPONSE_COLUMNS = [ assessorName, date, dateOfData, - approvalStatus, + responseStatus, { Header: 'Export', type: 'export', diff --git a/packages/admin-panel/src/routes/surveys/surveys.js b/packages/admin-panel/src/routes/surveys/surveys.js index d486eb286f..eb52ca677b 100644 --- a/packages/admin-panel/src/routes/surveys/surveys.js +++ b/packages/admin-panel/src/routes/surveys/surveys.js @@ -6,6 +6,8 @@ import { SurveyEditFields } from '../../surveys/SurveyEditFields'; import { QUESTION_FIELDS as BASE_QUESTION_FIELDS } from './questions'; import { EditSurveyPage } from '../../pages/resources'; +const { REACT_APP_DATATRAK_WEB_URL } = import.meta.env; + const RESOURCE_NAME = { singular: 'survey' }; const PERIOD_GRANULARITIES = [ @@ -175,6 +177,16 @@ const SURVEY_COLUMNS = [ SURVEY_FIELDS.project, SURVEY_FIELDS.name, SURVEY_FIELDS.code, + { + Header: 'Project ID', + source: 'project.id', + show: false, + }, + { + Header: 'countries', + source: 'countryCodes', + show: false, + }, { Header: 'Permission group', source: 'permission_group.name', @@ -183,6 +195,19 @@ const SURVEY_COLUMNS = [ Header: 'Survey group', source: 'survey_group.name', }, + { + Header: 'View', + type: 'externalLink', + actionConfig: { + generateUrl: row => { + const { code, countryCodes } = row; + if (!countryCodes || !countryCodes.some(countryCode => !!countryCode)) return null; + const countryCodeToUse = countryCodes.includes('DL') ? 'DL' : countryCodes[0]; + return `${REACT_APP_DATATRAK_WEB_URL}/survey/${countryCodeToUse}/${code}/1`; + }, + title: 'View in DataTrak', + }, + }, { Header: 'Export', type: 'export', diff --git a/packages/admin-panel/src/surveyResponse/Form.jsx b/packages/admin-panel/src/surveyResponse/Form.jsx index 435bda8b32..4bfbcd9e76 100644 --- a/packages/admin-panel/src/surveyResponse/Form.jsx +++ b/packages/admin-panel/src/surveyResponse/Form.jsx @@ -9,7 +9,7 @@ import styled from 'styled-components'; import { Button } from '@tupaia/ui-components'; import { useGetExistingData } from './useGetExistingData'; import { ModalContentProvider, ModalFooter } from '../widgets'; -import { useResubmitSurveyResponse } from '../api/mutations/useResubmitSurveyResponse'; +import { useEditSurveyResponse } from '../api/mutations/useEditSurveyResponse'; import { ResponseFields } from './ResponseFields'; const ButtonGroup = styled.div` @@ -19,23 +19,23 @@ const ButtonGroup = styled.div` `; export const Form = ({ surveyResponseId, onDismiss, onAfterMutate }) => { - const [surveyResubmission, setSurveyResubmission] = useState({}); - const isUnchanged = Object.keys(surveyResubmission).length === 0; + const [editedData, setEditedData] = useState({}); + const isUnchanged = Object.keys(editedData).length === 0; const [selectedEntity, setSelectedEntity] = useState({}); const { - mutateAsync: resubmitResponse, + mutateAsync: editResponse, isLoading, isError, - error: resubmitError, + error: editError, reset, // reset the mutation state so we can dismiss the error isSuccess, - } = useResubmitSurveyResponse(surveyResponseId, surveyResubmission); + } = useEditSurveyResponse(surveyResponseId, editedData); const { data, isLoading: isFetching, error: fetchError } = useGetExistingData(surveyResponseId); - const existingAndNewFields = { ...data?.surveyResponse, ...surveyResubmission }; + const existingAndNewFields = { ...data?.surveyResponse, ...editedData }; useEffect(() => { if (!data) { @@ -46,7 +46,7 @@ export const Form = ({ surveyResponseId, onDismiss, onAfterMutate }) => { }, [data]); const resubmitSurveyResponse = async () => { - await resubmitResponse(); + await editResponse(); onAfterMutate(); }; @@ -61,7 +61,7 @@ export const Form = ({ surveyResponseId, onDismiss, onAfterMutate }) => { const resubmitResponseAndRedirect = async () => { // If the response has been changed, resubmit it before redirecting if (!isUnchanged) { - await resubmitResponse(); + await editResponse(); onAfterMutate(); } const { country_code: updatedCountryCode } = selectedEntity; @@ -107,17 +107,14 @@ export const Form = ({ surveyResponseId, onDismiss, onAfterMutate }) => { return ( <> - + {!isFetching && !isSuccess && ( - setSurveyResubmission({ ...surveyResubmission, [field]: updatedField }) + setEditedData({ ...editedData, [field]: updatedField }) } setSelectedEntity={setSelectedEntity} /> diff --git a/packages/admin-panel/src/table/columnTypes/ExternalLinkButton.jsx b/packages/admin-panel/src/table/columnTypes/ExternalLinkButton.jsx new file mode 100644 index 0000000000..f8a55c9034 --- /dev/null +++ b/packages/admin-panel/src/table/columnTypes/ExternalLinkButton.jsx @@ -0,0 +1,39 @@ +/* + * Tupaia + * Copyright (c) 2017 - 2024 Beyond Essential Systems Pty Ltd + */ + +import React from 'react'; +import PropTypes from 'prop-types'; +import { Link } from '@material-ui/core'; +import { OpenInNewRounded } from '@material-ui/icons'; +import { ColumnActionButton } from './ColumnActionButton'; +import { makeSubstitutionsInString } from '../../utilities'; + +export const ExternalLinkButton = ({ actionConfig, row }) => { + const getUrl = () => { + if (actionConfig.generateUrl) { + return actionConfig.generateUrl(row.original); + } + return makeSubstitutionsInString(actionConfig.url, row.original); + }; + const fullUrl = getUrl(); + if (!fullUrl) return null; + + return ( + + + + ); +}; + +ExternalLinkButton.propTypes = { + actionConfig: PropTypes.object.isRequired, + row: PropTypes.object.isRequired, +}; diff --git a/packages/admin-panel/src/table/columnTypes/ResubmitSurveyResponseButton.jsx b/packages/admin-panel/src/table/columnTypes/ResubmitSurveyResponseButton.jsx index 727bdbda69..6ea2961b4c 100644 --- a/packages/admin-panel/src/table/columnTypes/ResubmitSurveyResponseButton.jsx +++ b/packages/admin-panel/src/table/columnTypes/ResubmitSurveyResponseButton.jsx @@ -10,7 +10,8 @@ import EditIcon from '@material-ui/icons/Edit'; import { openResubmitSurveyResponseModal } from '../../surveyResponse/actions'; import { ColumnActionButton } from './ColumnActionButton'; -export const ResubmitSurveyResponseButtonComponent = ({ openModal }) => { +export const ResubmitSurveyResponseButtonComponent = ({ openModal, row }) => { + if (row.original.outdated) return null; return ( @@ -20,6 +21,11 @@ export const ResubmitSurveyResponseButtonComponent = ({ openModal }) => { ResubmitSurveyResponseButtonComponent.propTypes = { openModal: PropTypes.func.isRequired, + row: PropTypes.shape({ + original: PropTypes.shape({ + outdated: PropTypes.bool.isRequired, + }).isRequired, + }).isRequired, }; const mapDispatchToProps = (dispatch, ownProps) => { diff --git a/packages/admin-panel/src/table/columnTypes/columnFilters.jsx b/packages/admin-panel/src/table/columnTypes/columnFilters.jsx index 2bb2eea429..a5ea40fe9c 100644 --- a/packages/admin-panel/src/table/columnTypes/columnFilters.jsx +++ b/packages/admin-panel/src/table/columnTypes/columnFilters.jsx @@ -72,6 +72,41 @@ BooleanSelectFilter.defaultProps = { filter: {}, }; +/* + * Makes outdated field work with the database filter + */ + +export const OutdatedFilter = ({ filter, onChange, column }) => { + return ( + props => ; @@ -40,6 +41,7 @@ const CUSTOM_CELL_COMPONENTS = { testDatabaseConnection: TestDatabaseConnectionButton, qrCode: QrCodeButton, resubmitSurveyResponse: ResubmitSurveyResponseButton, + externalLink: ExternalLinkButton, }; const BUTTON_COLUMN_TYPES = [ @@ -51,6 +53,7 @@ const BUTTON_COLUMN_TYPES = [ 'qrCode', 'testDatabaseConnection', 'bulkEdit', + 'externalLink', 'sync', ]; diff --git a/packages/admin-panel/src/utilities/makeSubstitutionsInString.js b/packages/admin-panel/src/utilities/makeSubstitutionsInString.js index 7028b17436..c16fb552a7 100644 --- a/packages/admin-panel/src/utilities/makeSubstitutionsInString.js +++ b/packages/admin-panel/src/utilities/makeSubstitutionsInString.js @@ -4,7 +4,7 @@ */ const extractParams = template => - [...template.matchAll(/\{(\w+)\}/gi)].map(matchArray => matchArray[1]); + [...template.matchAll(/(?<=\{)(.*?)(?=\})/gi)].map(matchArray => matchArray[1]); export const makeSubstitutionsInString = (template, variables) => { const params = extractParams(template); diff --git a/packages/api-client/src/connections/CentralApi.ts b/packages/api-client/src/connections/CentralApi.ts index 8755ddff16..637db38264 100644 --- a/packages/api-client/src/connections/CentralApi.ts +++ b/packages/api-client/src/connections/CentralApi.ts @@ -63,6 +63,18 @@ export class CentralApi extends BaseApi { } } + public async resubmitSurveyResponse( + originalResponseId: string, + newResponse: MeditrakSurveyResponseRequest, + queryParameters?: QueryParameters, + ): Promise { + return this.connection.post( + `surveyResponses/${originalResponseId}/resubmit`, + queryParameters, + newResponse, + ); + } + public async fetchResources(endpoint: string, params?: Record) { return this.connection.get(endpoint, stringifyParams(params)); } diff --git a/packages/api-client/src/connections/mocks/MockCentralApi.ts b/packages/api-client/src/connections/mocks/MockCentralApi.ts index 55efc849c2..b750be91f0 100644 --- a/packages/api-client/src/connections/mocks/MockCentralApi.ts +++ b/packages/api-client/src/connections/mocks/MockCentralApi.ts @@ -79,6 +79,12 @@ export class MockCentralApi implements CentralApiInterface { public createSurveyResponses(responses: MeditrakSurveyResponseRequest[]): Promise { throw new Error('Method not implemented.'); } + public resubmitSurveyResponse( + originalResponseId: string, + newResponse: MeditrakSurveyResponseRequest, + ): Promise { + throw new Error('Method not implemented.'); + } public async fetchResources(endpoint: string, params?: Params): Promise { const resourceData = this.mockData[endpoint]; if (!resourceData) return []; diff --git a/packages/central-server/src/apiV2/export/exportSurveyResponses/exportResponsesToFile.js b/packages/central-server/src/apiV2/export/exportSurveyResponses/exportResponsesToFile.js index 2d1df9a688..c5c1222825 100644 --- a/packages/central-server/src/apiV2/export/exportSurveyResponses/exportResponsesToFile.js +++ b/packages/central-server/src/apiV2/export/exportSurveyResponses/exportResponsesToFile.js @@ -165,6 +165,7 @@ export async function exportResponsesToFile( const findResponsesForSurvey = async survey => { const surveyResponseFindConditions = { survey_id: survey.id, + outdated: false, // only get the latest version of each survey response. Outdated responses are usually resubmissions }; const dataTimeCondition = getDataTimeCondition(); if (dataTimeCondition) { diff --git a/packages/central-server/src/apiV2/import/importSurveyResponses/SurveyResponseUpdatePersistor.js b/packages/central-server/src/apiV2/import/importSurveyResponses/SurveyResponseUpdatePersistor.js index c9663dc801..30085dbdbe 100644 --- a/packages/central-server/src/apiV2/import/importSurveyResponses/SurveyResponseUpdatePersistor.js +++ b/packages/central-server/src/apiV2/import/importSurveyResponses/SurveyResponseUpdatePersistor.js @@ -58,14 +58,15 @@ export class SurveyResponseUpdatePersistor { return Object.keys(this.updatesByResponseId).length; } - setupColumnsForSheet(sheetName, surveyResponseIds) { - surveyResponseIds.forEach((surveyResponseId, columnIndex) => { - if (!surveyResponseId) return; // array contains some empty slots representing info columns - this.updatesByResponseId[surveyResponseId] = { + setupColumnsForSheet(sheetName, surveyResponses) { + surveyResponses.forEach((surveyResponse, columnIndex) => { + if (!surveyResponse) return; // array contains some empty slots representing info columns + this.updatesByResponseId[surveyResponse.surveyResponseId] = { type: UPDATE, sheetName, columnIndex, - surveyResponseId, + surveyResponseId: surveyResponse.surveyResponseId, + entityId: surveyResponse.entityId, newSurveyResponse: null, // only populated if a new survey response is to be created newDataTime: null, // only populated if submission time is to be updated answers: { @@ -149,12 +150,12 @@ export class SurveyResponseUpdatePersistor { return { failures }; } - async processUpdate(transactingModels, { surveyResponseId, newDataTime, answers }) { + async processUpdate(transactingModels, { surveyResponseId, entityId, newDataTime, answers }) { + const newData = { entity_id: entityId }; if (newDataTime) { - await transactingModels.surveyResponse.updateById(surveyResponseId, { - data_time: newDataTime, - }); + newData.data_time = newDataTime; } + await transactingModels.surveyResponse.updateById(surveyResponseId, newData); await this.processUpsertAnswers(transactingModels, surveyResponseId, answers.upserts); await this.processDeleteAnswers(transactingModels, surveyResponseId, answers.deletes); } @@ -204,6 +205,7 @@ export class SurveyResponseUpdatePersistor { async process() { const allUpdates = Object.values(this.updatesByResponseId); + const creates = allUpdates.filter(({ type }) => type === CREATE); const { failures: createFailures } = await this.processCreates(creates); diff --git a/packages/central-server/src/apiV2/import/importSurveyResponses/importSurveyResponses.js b/packages/central-server/src/apiV2/import/importSurveyResponses/importSurveyResponses.js index d8828390ff..f68ef08363 100644 --- a/packages/central-server/src/apiV2/import/importSurveyResponses/importSurveyResponses.js +++ b/packages/central-server/src/apiV2/import/importSurveyResponses/importSurveyResponses.js @@ -148,7 +148,7 @@ export async function importSurveyResponses(req, res) { // extract response ids and set up update batcher const { maxColumnIndex, maxRowIndex } = getMaxRowColumnIndex(sheet); const minSurveyResponseIndex = INFO_COLUMN_HEADERS.length; - const surveyResponseIds = []; + const surveyResponses = []; const isGeneratedIdByColumnIndex = []; const existingResponseDataByColumnIndex = []; @@ -168,32 +168,54 @@ export async function importSurveyResponses(req, res) { for (let columnIndex = minSurveyResponseIndex; columnIndex <= maxColumnIndex; columnIndex++) { const columnHeader = getColumnHeader(sheet, columnIndex); const importMode = getImportMode(columnHeader); + const entityCode = getInfoForColumn(sheet, columnIndex, 'Entity Code'); + const entityName = getInfoForColumn(sheet, columnIndex, 'Entity Name'); + const entity = await models.entity.findOne({ code: entityCode }); + + if (entityCode && entityName) { + if (!entity) { + throw new ImportValidationError( + `Entity code does match any existing entity: ${entityCode}`, + ); + } + if (entity.name !== entityName) { + throw new ImportValidationError( + `Entity code and name don't match: ${entity?.name} and ${entityName}`, + ); + } + } + + let surveyResponseIdValue = null; if (IMPORT_BEHAVIOURS[importMode].shouldGenerateIds) { - surveyResponseIds[columnIndex] = generateId(); + surveyResponseIdValue = generateId(); isGeneratedIdByColumnIndex[columnIndex] = true; } else if (IMPORT_BEHAVIOURS[importMode].shouldUpdateExistingResponses) { const { surveyResponseId } = await getExistingResponseData(columnIndex); if (surveyResponseId) { - surveyResponseIds[columnIndex] = surveyResponseId; + surveyResponseIdValue = surveyResponseId; } else { // A matching existing response was not found, generate a new id - surveyResponseIds[columnIndex] = generateId(); + surveyResponseIdValue = generateId(); isGeneratedIdByColumnIndex[columnIndex] = true; } } else { - surveyResponseIds[columnIndex] = columnHeader; + surveyResponseIdValue = columnHeader; } + surveyResponses[columnIndex] = { + surveyResponseId: surveyResponseIdValue, + entityId: entity?.id, + }; } - updatePersistor.setupColumnsForSheet(tabName, surveyResponseIds); + updatePersistor.setupColumnsForSheet(tabName, surveyResponses); for (let columnIndex = minSurveyResponseIndex; columnIndex <= maxColumnIndex; columnIndex++) { const columnHeader = getColumnHeader(sheet, columnIndex); validateColumnHeader(columnHeader, columnIndex, tabName); if (isGeneratedIdByColumnIndex[columnIndex]) { - const surveyResponseId = surveyResponseIds[columnIndex]; + const { surveyResponseId } = surveyResponses[columnIndex]; const surveyResponseDetails = await constructNewSurveyResponseDetails( models, sheet, @@ -257,9 +279,9 @@ export async function importSurveyResponses(req, res) { columnIndex <= maxColumnIndex; columnIndex++ ) { + const { surveyResponseId } = surveyResponses[columnIndex]; const columnHeader = getColumnHeader(sheet, columnIndex); const importMode = getImportMode(columnHeader); - const surveyResponseId = surveyResponseIds[columnIndex]; const answerValue = getCellContents(sheet, columnIndex, rowIndex); const transformedAnswerValue = answerTransformer ? await answerTransformer(models, answerValue) @@ -331,7 +353,7 @@ export async function importSurveyResponses(req, res) { const message = failures.length > 0 ? `Not all responses were successfully processed: - ${failures.map(getFailureMessage).join('\n')}` + ${failures.map(getFailureMessage).join('\n')}` : null; respond(res, { message, failures }); } catch (error) { diff --git a/packages/central-server/src/apiV2/import/importSurveys/Validator/ConfigValidator/EntityConfigValidator.js b/packages/central-server/src/apiV2/import/importSurveys/Validator/ConfigValidator/EntityConfigValidator.js index efdcfd6f75..29c7f8fcc1 100644 --- a/packages/central-server/src/apiV2/import/importSurveys/Validator/ConfigValidator/EntityConfigValidator.js +++ b/packages/central-server/src/apiV2/import/importSurveys/Validator/ConfigValidator/EntityConfigValidator.js @@ -90,6 +90,9 @@ export class EntityConfigValidator extends JsonFieldValidator { pointsToPrecedingEntityQuestion(...params), ); + const pointsToMandatoryCodeGeneratorQuestion = + this.constructPointsToMandatoryCodeGeneratorQuestion(rowIndex); + const pointsToAnotherMandatoryQuestion = this.constructPointsToMandatoryQuestion(rowIndex); return { @@ -100,7 +103,7 @@ export class EntityConfigValidator extends JsonFieldValidator { 'attributes.type': [constructIsNotPresentOr(pointsToAnotherQuestion)], 'fields.code': [ hasContentOnlyIfCanCreateNew, - constructIsNotPresentOr(pointsToAnotherMandatoryQuestion), + constructIsNotPresentOr(pointsToMandatoryCodeGeneratorQuestion), ], 'fields.name': [ hasContentIfCanCreateNew, @@ -123,6 +126,23 @@ export class EntityConfigValidator extends JsonFieldValidator { }; } + constructPointsToMandatoryCodeGeneratorQuestion(rowIndex) { + return value => { + const questionCode = value; + const question = this.findOtherQuestion(questionCode, rowIndex, this.questions.length); + + if (!question) { + throw new ValidationError(`Should reference another code generator question in the survey`); + } + + if (question.type !== 'CodeGenerator') { + throw new ValidationError(`Referenced question should be a code generator question`); + } + + return true; + }; + } + constructPointsToMandatoryQuestion(rowIndex) { return value => { const questionCode = value; @@ -141,7 +161,7 @@ export class EntityConfigValidator extends JsonFieldValidator { if (!parsedValidationCriteria.mandatory || parsedValidationCriteria.mandatory !== 'true') { throw new ValidationError(`Referenced question should be mandatory`); - } + } return true; }; diff --git a/packages/central-server/src/apiV2/index.js b/packages/central-server/src/apiV2/index.js index bf2a97dc7f..1d605fed9c 100644 --- a/packages/central-server/src/apiV2/index.js +++ b/packages/central-server/src/apiV2/index.js @@ -73,6 +73,7 @@ import { GETSurveyResponses, SubmitSurveyResponses, ResubmitSurveyResponse, + EditSurveyResponse, } from './surveyResponses'; import { DeleteSurveyScreenComponents, @@ -286,7 +287,7 @@ apiV2.post('/me/changePassword', catchAsyncErrors(changePassword)); apiV2.post('/surveyResponse', useRouteHandler(SubmitSurveyResponses)); // used by mSupply to directly submit data apiV2.post('/surveyResponses', useRouteHandler(SubmitSurveyResponses)); apiV2.post( - '/surveyResponse/:recordId/resubmit', + '/surveyResponses/:recordId/resubmit', multipartJson(false), useRouteHandler(ResubmitSurveyResponse), ); @@ -354,6 +355,7 @@ apiV2.put('/surveys/:recordId', multipartJson(), useRouteHandler(EditSurvey)); apiV2.put('/dhisInstances/:recordId', useRouteHandler(BESAdminEditHandler)); apiV2.put('/supersetInstances/:recordId', useRouteHandler(BESAdminEditHandler)); apiV2.put('/tasks/:recordId', useRouteHandler(EditTask)); +apiV2.put('/surveyResponses/:recordId', useRouteHandler(EditSurveyResponse)); /** * DELETE routes diff --git a/packages/central-server/src/apiV2/surveyResponses/EditSurveyResponse.js b/packages/central-server/src/apiV2/surveyResponses/EditSurveyResponse.js new file mode 100644 index 0000000000..0748b354b2 --- /dev/null +++ b/packages/central-server/src/apiV2/surveyResponses/EditSurveyResponse.js @@ -0,0 +1,25 @@ +/** + * Tupaia + * Copyright (c) 2017 - 2024 Beyond Essential Systems Pty Ltd + */ + +import { assertAnyPermissions, assertBESAdminAccess } from '../../permissions'; +import { EditHandler } from '../EditHandler'; +import { assertSurveyResponsePermissions } from './assertSurveyResponsePermissions'; + +export class EditSurveyResponse extends EditHandler { + async assertUserHasAccess() { + // Check the user has either: + // - BES admin access + // - Permission to view the surveyResponse AND Tupaia Admin Panel access anywhere + const surveyResponsePermissionChecker = accessPolicy => + assertSurveyResponsePermissions(accessPolicy, this.models, this.recordId); + await this.assertPermissions( + assertAnyPermissions([assertBESAdminAccess, surveyResponsePermissionChecker]), + ); + } + + async editRecord() { + await this.updateRecord(); + } +} diff --git a/packages/central-server/src/apiV2/surveyResponses/ResubmitSurveyResponse.js b/packages/central-server/src/apiV2/surveyResponses/ResubmitSurveyResponse.js index 099ce8802a..0c70d55ea1 100644 --- a/packages/central-server/src/apiV2/surveyResponses/ResubmitSurveyResponse.js +++ b/packages/central-server/src/apiV2/surveyResponses/ResubmitSurveyResponse.js @@ -4,95 +4,80 @@ * Copyright (c) 2017 - 2020 Beyond Essential Systems Pty Ltd */ -import { S3, S3Client } from '@tupaia/server-utils'; -import { AnalyticsRefresher } from '@tupaia/database'; -import fs from 'fs'; -import { EditHandler } from '../EditHandler'; +import { respond } from '@tupaia/utils'; import { assertAllPermissions, assertAnyPermissions, assertBESAdminAccess, assertAdminPanelAccess, } from '../../permissions'; -import { - assertSurveyResponsePermissions, - assertSurveyResponseEditPermissions, -} from './assertSurveyResponsePermissions'; -import { handleSurveyResponse, handleAnswers } from './resubmission/handleResubmission'; -import { validateResubmission } from './resubmission/validateResubmission'; +import { assertSurveyResponsePermissions } from './assertSurveyResponsePermissions'; +import { RouteHandler } from '../RouteHandler'; +import { validateSurveyResponse } from './validateSurveyResponses'; +import { assertCanSubmitSurveyResponses } from '../import/importSurveyResponses/assertCanImportSurveyResponses'; +import { upsertEntitiesAndOptions } from './upsertEntitiesAndOptions'; +import { saveResponsesToDatabase } from './saveResponsesToDatabase'; /** * Handles POST endpoint: * - /surveyResponses/:surveyResponseId/resubmit - * handles both edits and creation of new answers + * + * Creates a new survey response and flags the previous one as `outdated=true` */ -export class ResubmitSurveyResponse extends EditHandler { +export class ResubmitSurveyResponse extends RouteHandler { + constructor(req, res) { + super(req, res); + this.originalSurveyResponseId = this.params.recordId; + this.newSurveyResponse = req.body; + } + async assertUserHasAccess() { // Check the user has either: // - BES admin access - // - Permission to view the surveyResponse AND Tupaia Admin Panel access anywhere - const surveyResponsePermissionChecker = accessPolicy => - assertSurveyResponsePermissions(accessPolicy, this.models, this.recordId); + // - Tupaia Admin Panel access AND permission to view the surveyResponse AND permission to submit the new survey response + const originalSurveyResponsePermissionChecker = accessPolicy => + assertSurveyResponsePermissions(accessPolicy, this.models, this.originalSurveyResponseId); + + const newSurveyResponsePermissionsChecker = async accessPolicy => { + await assertCanSubmitSurveyResponses(accessPolicy, this.models, [this.newSurveyResponse]); + }; await this.assertPermissions( assertAnyPermissions([ assertBESAdminAccess, - assertAllPermissions([assertAdminPanelAccess, surveyResponsePermissionChecker]), + assertAllPermissions([ + assertAdminPanelAccess, + originalSurveyResponsePermissionChecker, + newSurveyResponsePermissionsChecker, + ]), ]), ); } - async editRecord() { - // Check we aren't editing the surveyResponse in a way that could break something - const surveyResponseEditPermissionChecker = accessPolicy => - assertSurveyResponseEditPermissions( - accessPolicy, - this.models, - this.recordId, - this.updatedFields, - ); - await this.assertPermissions( - assertAnyPermissions([assertBESAdminAccess, surveyResponseEditPermissionChecker]), + async handleRequest() { + // Upsert entities and options that were created in user's local database + const originalSurveyResponse = await this.models.surveyResponse.findById( + this.originalSurveyResponseId, ); + if (!originalSurveyResponse) { + throw new Error( + `Cannot find original survey response with id: ${this.originalSurveyResponseId}`, + ); + } await this.models.wrapInTransaction(async transactingModels => { - const currentSurveyResponse = await transactingModels.surveyResponse.findOne({ - id: this.recordId, + await upsertEntitiesAndOptions(transactingModels, [this.newSurveyResponse]); + await validateSurveyResponse(transactingModels, this.newSurveyResponse); + await this.assertUserHasAccess(); + await saveResponsesToDatabase(transactingModels, originalSurveyResponse.user_id, [ + this.newSurveyResponse, + ]); + await transactingModels.surveyResponse.updateById(this.originalSurveyResponseId, { + outdated: true, }); - if (!currentSurveyResponse) { - throw Error('Survey response not found.'); - } - await validateResubmission(transactingModels, this.updatedFields, currentSurveyResponse); - await handleAnswers(this.models, this.updatedFields, currentSurveyResponse); - await handleSurveyResponse( - this.models, - this.updatedFields, - this.recordType, - currentSurveyResponse, - ); - if (this.req.files) { - // Upload files last so that we don't end up with uploaded files if db changes fail - for (const file of this.req.files) { - const uniqueFileName = file.fieldname; - const readableStream = fs.createReadStream(file.path); // see https://github.com/aws/aws-sdk-js-v3/issues/2522 - const s3Client = this.getS3client(); - await s3Client.uploadFile(uniqueFileName, readableStream); - } - } - - if (this.req.query.waitForAnalyticsRebuild) { - const { database } = transactingModels; - await AnalyticsRefresher.refreshAnalytics(database); - } }); - } - // Workaround to allow us to test this route, remove after S3Client is mocked after RN-982 - getS3client() { - if (!this.s3Client) { - this.s3Client = new S3Client(new S3()); - } - return this.s3Client; + respond(this.res, { message: 'Successfully resubmitted the response' }); } } diff --git a/packages/central-server/src/apiV2/surveyResponses/index.js b/packages/central-server/src/apiV2/surveyResponses/index.js index 89c2345890..c7ee4a19d6 100644 --- a/packages/central-server/src/apiV2/surveyResponses/index.js +++ b/packages/central-server/src/apiV2/surveyResponses/index.js @@ -11,3 +11,4 @@ export { upsertEntitiesAndOptions } from './upsertEntitiesAndOptions'; export { validateSurveyResponses } from './validateSurveyResponses'; export { ResubmitSurveyResponse } from './ResubmitSurveyResponse'; export { SubmitSurveyResponses } from './SubmitSurveyResponses'; +export { EditSurveyResponse } from './EditSurveyResponse'; diff --git a/packages/central-server/src/apiV2/surveyResponses/resubmission/handleResubmission.js b/packages/central-server/src/apiV2/surveyResponses/resubmission/handleResubmission.js deleted file mode 100644 index af530ff433..0000000000 --- a/packages/central-server/src/apiV2/surveyResponses/resubmission/handleResubmission.js +++ /dev/null @@ -1,84 +0,0 @@ -/* eslint-disable camelcase */ -/** - * Tupaia - * Copyright (c) 2017 - 2023 Beyond Essential Systems Pty Ltd - */ - -import { QuestionType } from '@tupaia/types'; -import { findQuestionsInSurvey } from '../../../dataAccessors'; -import { S3, S3Client } from '@tupaia/server-utils'; -import { UploadError } from '@tupaia/utils'; - -export const handleSurveyResponse = async (models, updatedFields, recordType, surveyResponse) => { - const surveyResponseUpdateFields = { ...updatedFields }; - delete surveyResponseUpdateFields.answers; - if (Object.keys(surveyResponseUpdateFields).length < 1) { - return; - } - await models.surveyResponse.updateById(surveyResponse.id, surveyResponseUpdateFields); -}; - -export const handleAnswers = async (models, updatedFields, surveyResponse) => { - const { answers: updatedAnswers } = updatedFields; - - if (!updatedAnswers) { - return; - } - // check answer exists - const { survey_id: surveyId, id: surveyResponseId } = surveyResponse; - const surveyQuestions = await findQuestionsInSurvey(models, surveyId); - const codesToIds = {}; - surveyQuestions.forEach(({ id, code, type }) => { - codesToIds[code] = { id, type }; - }); - const questionCodes = Object.keys(updatedAnswers); - - await Promise.all( - questionCodes.map(async questionCode => { - const answer = updatedAnswers[questionCode]; - const isAnswerDeletion = updatedAnswers[questionCode] === null; - const { id, type } = codesToIds[questionCode]; - const existingAnswer = await models.answer.findOne({ - survey_response_id: surveyResponseId, - question_id: id, - }); - - // If the answer is a photo and the answer is updated and the value is a base64 encoded image, upload the image to S3 and update the answer to be the url - const validFileIdRegex = RegExp('^[a-f\\d]{24}$'); - if (type === QuestionType.Photo && answer && !validFileIdRegex.test(answer)) { - try { - const base64 = updatedAnswers[questionCode]; - const s3Client = new S3Client(new S3()); - updatedAnswers[questionCode] = await s3Client.uploadImage(base64); - } catch (error) { - throw new UploadError(error); - } - } - - if (!existingAnswer) { - if (isAnswerDeletion) { - return; - } - await models.answer.create({ - type, - survey_response_id: surveyResponseId, - question_id: id, - text: updatedAnswers[questionCode], - }); - return; - } - - if (isAnswerDeletion) { - await models.answer.delete({ id: existingAnswer.id }); - return; - } - - await models.answer.update( - { id: existingAnswer.id }, - { - text: updatedAnswers[questionCode], - }, - ); - }), - ); -}; diff --git a/packages/central-server/src/apiV2/surveyResponses/resubmission/validateResubmission.js b/packages/central-server/src/apiV2/surveyResponses/resubmission/validateResubmission.js deleted file mode 100644 index bedb35a8ce..0000000000 --- a/packages/central-server/src/apiV2/surveyResponses/resubmission/validateResubmission.js +++ /dev/null @@ -1,67 +0,0 @@ -/** - * Tupaia MediTrak - * Copyright (c) 2022-2023 Beyond Essential Systems Pty Ltd - */ - -import { - ValidationError, - ObjectValidator, - constructRecordExistsWithId, - constructIsEmptyOr, - takesDateForm, - isPlainObject, - isAString, -} from '@tupaia/utils'; -import { constructAnswerValidator } from '../../utilities/constructAnswerValidator'; -import { findQuestionsInSurvey } from '../../../dataAccessors'; - -export const validateResubmission = async (models, updatedFields, surveyResponse) => { - if (!updatedFields) { - throw new ValidationError('Survey responses must not be null'); - } - - const surveyResponseValidator = createSurveyResponseValidator(models); - await surveyResponseValidator.validate(updatedFields); - const { survey_id: surveyId } = surveyResponse; - const surveyQuestions = await findQuestionsInSurvey(models, surveyId); - - const { answers } = updatedFields; - if (!answers) { - return; - } - - const answerValidations = Object.entries(answers).map(async ([questionCode, value]) => { - const question = surveyQuestions.find(q => q.code === questionCode); - if (!question) { - throw new ValidationError( - `Could not find question with code ${questionCode} on survey ${surveyId}`, - ); - } - - if (value === null || value === undefined) { - // Answer deleted, valid - return; - } - - try { - const answerValidator = new ObjectValidator({}, constructAnswerValidator(models, question)); - await answerValidator.validate({ answer: value }); - } catch (e) { - // validator will always complain of field "answer" but in this context it is not - // particularly useful - throw new Error(e.message.replace('field "answer"', `question code "${questionCode}"`)); - } - }); - - await Promise.all(answerValidations); -}; - -const createSurveyResponseValidator = models => - new ObjectValidator({ - entity_id: [constructIsEmptyOr(constructRecordExistsWithId(models.entity))], - data_time: [constructIsEmptyOr(takesDateForm)], - end_time: [constructIsEmptyOr(takesDateForm)], - start_time: [constructIsEmptyOr(takesDateForm)], - answers: [constructIsEmptyOr(isPlainObject)], - approval_status: [constructIsEmptyOr(isAString)], - }); diff --git a/packages/central-server/src/apiV2/surveyResponses/upsertEntitiesAndOptions.js b/packages/central-server/src/apiV2/surveyResponses/upsertEntitiesAndOptions.js index 14a96bec75..c7a7790d63 100644 --- a/packages/central-server/src/apiV2/surveyResponses/upsertEntitiesAndOptions.js +++ b/packages/central-server/src/apiV2/surveyResponses/upsertEntitiesAndOptions.js @@ -10,18 +10,28 @@ const upsertEntities = async (models, entitiesUpserted, surveyId) => { const dataGroup = await survey.dataGroup(); return Promise.all( - entitiesUpserted.map(async entity => - models.entity.updateOrCreate( + entitiesUpserted.map(async entity => { + const existingEntity = await models.entity.findOne({ id: entity.id }); + + const existingMetadata = existingEntity?.metadata || {}; + + return models.entity.updateOrCreate( { id: entity.id }, { ...entity, metadata: dataGroup.service_type === 'dhis' - ? { dhis: { isDataRegional: !!dataGroup.config.isDataRegional } } + ? { + ...existingMetadata, + dhis: { + ...existingMetadata?.dhis, + isDataRegional: !!dataGroup.config.isDataRegional, + }, + } : {}, }, - ), - ), + ); + }), ); }; diff --git a/packages/central-server/src/apiV2/surveyResponses/validateSurveyResponses.js b/packages/central-server/src/apiV2/surveyResponses/validateSurveyResponses.js index 210ce6b0a5..5f6ad0c0af 100644 --- a/packages/central-server/src/apiV2/surveyResponses/validateSurveyResponses.js +++ b/packages/central-server/src/apiV2/surveyResponses/validateSurveyResponses.js @@ -34,7 +34,7 @@ const createSurveyResponseValidator = models => end_time: [constructIsEmptyOr(takesDateForm)], }); -const validateResponse = async (models, body) => { +export const validateSurveyResponse = async (models, body) => { if (!body) { throw new ValidationError('Survey responses must not be null'); } @@ -94,7 +94,7 @@ export const validateSurveyResponses = async (models, responses) => { const validations = await Promise.all( responses.map(async (r, i) => { try { - await validateResponse(models, r); + await validateSurveyResponse(models, r); return null; } catch (e) { return { row: i, error: e.message }; diff --git a/packages/central-server/src/apiV2/surveys/GETSurveys.js b/packages/central-server/src/apiV2/surveys/GETSurveys.js index f4626ac224..5b77c04476 100644 --- a/packages/central-server/src/apiV2/surveys/GETSurveys.js +++ b/packages/central-server/src/apiV2/surveys/GETSurveys.js @@ -25,6 +25,7 @@ import { processColumns } from '../GETHandler/helpers'; const SURVEY_QUESTIONS_COLUMN = 'surveyQuestions'; const COUNTRY_NAMES_COLUMN = 'countryNames'; +const COUNTRY_CODES_COLUMN = 'countryCodes'; export class GETSurveys extends GETHandler { permissionsFilteredInternally = true; @@ -44,11 +45,13 @@ export class GETSurveys extends GETHandler { // 2. Add countryNames const countryNames = await this.getSurveyCountryNames([surveyId]); + const countryCodes = await this.getSurveyCountryCodes([surveyId]); return { ...survey, surveyQuestions: surveyQuestionsValues[surveyId], countryNames: countryNames[surveyId], + countryCodes: countryCodes[surveyId], }; } @@ -65,10 +68,16 @@ export class GETSurveys extends GETHandler { records.filter(record => record.id).map(record => record.id), ); + // 3. Add countryCodes + const countryCodes = await this.getSurveyCountryCodes( + records.filter(record => record.id).map(record => record.id), + ); + return records.map(record => ({ ...record, surveyQuestions: surveyQuestionsValues[record.id], countryNames: countryNames[record.id], + countryCodes: countryCodes[record.id], })); } @@ -104,9 +113,10 @@ export class GETSurveys extends GETHandler { // If we've requested specific columns, we allow skipping these fields by not requesting them this.includeQuestions = parsedColumns.includes(SURVEY_QUESTIONS_COLUMN); this.includeCountryNames = parsedColumns.includes(COUNTRY_NAMES_COLUMN); + this.includeCountryCodes = parsedColumns.includes(COUNTRY_CODES_COLUMN); const unprocessedColumns = parsedColumns.filter( - col => ![SURVEY_QUESTIONS_COLUMN, COUNTRY_NAMES_COLUMN].includes(col), + col => ![SURVEY_QUESTIONS_COLUMN, COUNTRY_NAMES_COLUMN, COUNTRY_CODES_COLUMN].includes(col), ); return processColumns(this.models, unprocessedColumns, this.recordType); } @@ -162,6 +172,21 @@ export class GETSurveys extends GETHandler { ); return Object.fromEntries(rows.map(row => [row.id, row.country_names])); } + + async getSurveyCountryCodes(surveyIds) { + if (surveyIds.length === 0 || !this.includeCountryCodes) return {}; + const rows = await this.database.executeSql( + ` + SELECT survey.id, array_agg(country.code) as country_codes + FROM survey + LEFT JOIN country ON (country.id = any(survey.country_ids)) + WHERE survey.id in (${surveyIds.map(() => '?').join(',')}) + GROUP BY survey.id; + `, + surveyIds, + ); + return Object.fromEntries(rows.map(row => [row.id, row.country_codes.sort()])); + } } const getAggregatedQuestions = rawResults => { diff --git a/packages/central-server/src/dataAccessors/upsertAnswers.js b/packages/central-server/src/dataAccessors/upsertAnswers.js index 721e2b4d1c..07dadd80c9 100644 --- a/packages/central-server/src/dataAccessors/upsertAnswers.js +++ b/packages/central-server/src/dataAccessors/upsertAnswers.js @@ -18,9 +18,13 @@ export async function upsertAnswers(models, answers, surveyResponseId) { }; if (answer.type === QuestionType.Photo) { const validFileIdRegex = RegExp('^[a-f\\d]{24}$'); - if (validFileIdRegex.test(answer.body)) { + const s3ImagePath = getS3ImageFilePath(); + + if (answer.body.includes('http')) { + answerDocument.text = answer.body; + } else if (validFileIdRegex.test(answer.body)) { // if this is passed a valid id in the answer body - answerDocument.text = `${S3_BUCKET_PATH}${getS3ImageFilePath()}${answer.body}.png`; + answerDocument.text = `${S3_BUCKET_PATH}${s3ImagePath}${answer.body}.png`; } else { // included for backwards compatibility passing base64 strings for images, and for datatrak-web to upload images in answers try { @@ -38,10 +42,9 @@ export async function upsertAnswers(models, answers, surveyResponseId) { ) { try { const s3Client = new S3Client(new S3()); - answerDocument.text = await s3Client.uploadFile( - answer.body.uniqueFileName, - answer.body.data, - ); + await s3Client.uploadFile(answer.body.uniqueFileName, answer.body.data); + + answerDocument.text = answer.body.uniqueFileName; } catch (error) { throw new UploadError(error); } diff --git a/packages/central-server/src/dhis/DhisChangeValidator.js b/packages/central-server/src/dhis/DhisChangeValidator.js index 7712b36bf2..10c657dd68 100644 --- a/packages/central-server/src/dhis/DhisChangeValidator.js +++ b/packages/central-server/src/dhis/DhisChangeValidator.js @@ -7,7 +7,8 @@ import { getUniqueEntries } from '@tupaia/utils'; import { ChangeValidator } from '../externalApiSync'; export class DhisChangeValidator extends ChangeValidator { - queryValidSurveyResponseIds = async (surveyResponseIds, excludeEventBased = false) => { + queryValidSurveyResponseIds = async (surveyResponseIds, settings = {}) => { + const { excludeEventBased = false, outdated = false } = settings; const nonPublicDemoLandUsers = ( await this.models.database.executeSql( ` @@ -20,10 +21,12 @@ export class DhisChangeValidator extends ChangeValidator { `, ) ).map(r => r.user_id); + const validSurveyResponseIds = []; const batchSize = this.models.database.maxBindingsPerQuery - nonPublicDemoLandUsers.length; for (let i = 0; i < surveyResponseIds.length; i += batchSize) { const batchOfSurveyResponseIds = surveyResponseIds.slice(i, i + batchSize); + const batchOfSurveyResponses = await this.models.database.executeSql( ` SELECT DISTINCT survey_response.id as id @@ -41,31 +44,129 @@ export class DhisChangeValidator extends ChangeValidator { ? `AND ${this.models.surveyResponse.getExcludeEventsQueryClause()}` : '' } + AND survey_response.outdated = ? AND survey_response.id IN (${batchOfSurveyResponseIds.map(() => '?').join(',')}); `, - [...nonPublicDemoLandUsers, ...batchOfSurveyResponseIds], + [...nonPublicDemoLandUsers, outdated, ...batchOfSurveyResponseIds], ); + validSurveyResponseIds.push(...batchOfSurveyResponses.map(r => r.id)); } return validSurveyResponseIds; }; + getAssociatedAnswers = async (surveyResponseIds, allChanges) => { + const answerChanges = allChanges.filter(c => c.record_type === 'answer'); + // get the answers associated with the survey responses + const associatedAnswers = await this.models.answer.find({ + survey_response_id: surveyResponseIds, + }); + + const filteredAnswers = associatedAnswers.filter(a => { + const change = answerChanges.find(c => c.record_id === a.id); + if (change) { + return false; + } + return true; + }); + + if (filteredAnswers.length === 0) return []; + + // filter out answers for questions that do not sync to dhis + const dhisLinkedAnswers = await this.filterDhisLinkedAnswers(filteredAnswers); + + return dhisLinkedAnswers; + }; + + getDeletesForAssociatedAnswers = async (surveyResponseIds, allChanges) => { + const answersToChange = await this.getAssociatedAnswers(surveyResponseIds, allChanges); + + // create delete changes for the answers + const outdatedAnswerDeletes = await Promise.all( + answersToChange.map(async a => ({ + record_type: 'answer', + record_id: a.id, + type: 'delete', + new_record: null, + old_record: await a.getData(), + })), + ); + + return outdatedAnswerDeletes; + }; + + getOutdatedAnswersAndSurveyResponses = async changes => { + // get the survey response changes that are already outdated + const surveyResponseChangesThatAreAlreadyOutdated = changes + .filter( + c => + c.record_type === 'survey_response' && + c.new_record && + c.new_record.outdated === true && + c.old_record && + c.old_record.outdated === true, + ) + .map(c => c.record_id); + // get the survey response ids that are being updated to 'outdated' + const surveyResponseUpdateIds = await this.getValidSurveyResponseUpdates(changes, { + excludeEventBased: false, + outdated: true, + }); + + // ignore survey response changes that are already outdated + const validSurveyResponseIds = surveyResponseUpdateIds.filter( + id => !surveyResponseChangesThatAreAlreadyOutdated.includes(id), + ); + + if (validSurveyResponseIds.length === 0) return []; + + // get the survey response changes that are being updated to 'outdated' + const surveyResponseChanges = this.filterChangesWithMatchingIds( + changes, + validSurveyResponseIds, + ); + + if (surveyResponseChanges.length === 0) return []; + + const outdatedSurveyResponseDeletes = surveyResponseChanges.map(s => ({ + ...s, + type: 'delete', + new_record: null, + })); + + // get the answers that are associated with the survey responses that are being updated to 'outdated' + const deleteAnswers = await this.getDeletesForAssociatedAnswers( + surveyResponseUpdateIds, + changes, + ); + + return [...outdatedSurveyResponseDeletes, ...deleteAnswers]; + }; + getValidDeletes = async changes => { - return this.getPreviouslySyncedDeletes( + const outdatedSurveyResponseDeletes = await this.getOutdatedAnswersAndSurveyResponses(changes); + + const previouslySyncedDeletes = await this.getPreviouslySyncedDeletes( changes, this.models.dhisSyncQueue, this.models.dhisSyncLog, ); + + return [...outdatedSurveyResponseDeletes, ...previouslySyncedDeletes]; }; getValidAnswerUpdates = async updateChanges => { const answers = this.getRecordsFromChangesForModel(updateChanges, this.models.answer); + if (answers.length === 0) return []; const surveyResponseIds = getUniqueEntries(answers.map(a => a.survey_response_id)); // check which survey responses are valid const validSurveyResponseIds = new Set( - await this.queryValidSurveyResponseIds(surveyResponseIds, true), + await this.queryValidSurveyResponseIds(surveyResponseIds, { + excludeEventBased: true, + outdated: false, + }), ); // filter out answers for questions that do not sync to dhis @@ -101,13 +202,14 @@ export class DhisChangeValidator extends ChangeValidator { return filteredAnswers; }; - getValidSurveyResponseUpdates = async updateChanges => { + getValidSurveyResponseUpdates = async (updateChanges, settings = {}) => { const surveyResponseIds = this.getIdsFromChangesForModel( updateChanges, this.models.surveyResponse, ); + if (surveyResponseIds.length === 0) return []; - return this.queryValidSurveyResponseIds(surveyResponseIds); + return this.queryValidSurveyResponseIds(surveyResponseIds, settings); }; getValidEntityUpdates = async updateChanges => { @@ -118,15 +220,48 @@ export class DhisChangeValidator extends ChangeValidator { return entities.filter(e => e.allowsPushingToDhis()).map(e => e.id); }; + // get associated answers for the survey responses that are being reinstated + getAnswersToUpdate = async allChanges => { + const surveyResponseChanges = await allChanges.filter( + c => + c.record_type === 'survey_response' && + c.old_record && + c.old_record.outdated === true && + c.new_record && + c.new_record.outdated === false, + ); + + const surveyResponseIdsToUpdate = surveyResponseChanges.map(c => c.record_id); + + const answerChanges = await this.getAssociatedAnswers(surveyResponseIdsToUpdate, allChanges); + return Promise.all( + answerChanges.map(async a => { + const data = await a.getData(); + return { + record_type: 'answer', + record_id: a.id, + type: 'update', + new_record: data, + old_record: data, + }; + }), + ); + }; + getValidUpdates = async changes => { const updateChanges = this.getUpdateChanges(changes); const validEntityIds = await this.getValidEntityUpdates(updateChanges); const validAnswerIds = await this.getValidAnswerUpdates(updateChanges); const validSurveyResponseIds = await this.getValidSurveyResponseUpdates(updateChanges); - return this.filterChangesWithMatchingIds(changes, [ - ...validEntityIds, - ...validAnswerIds, - ...validSurveyResponseIds, - ]); + const answersToUpdate = await this.getAnswersToUpdate(changes); + return [ + ...this.filterChangesWithMatchingIds(changes, [ + ...validEntityIds, + ...validAnswerIds, + ...validSurveyResponseIds, + ...answersToUpdate, + ]), + ...answersToUpdate, + ]; }; } diff --git a/packages/central-server/src/tests/apiV2/export/exportSurveyResponses/exportSurveyResponses.test.js b/packages/central-server/src/tests/apiV2/export/exportSurveyResponses/exportSurveyResponses.test.js index 48185a9d7f..dc7cf1c8d0 100644 --- a/packages/central-server/src/tests/apiV2/export/exportSurveyResponses/exportSurveyResponses.test.js +++ b/packages/central-server/src/tests/apiV2/export/exportSurveyResponses/exportSurveyResponses.test.js @@ -116,6 +116,7 @@ describe('exportSurveyResponses(): GET export/surveysResponses', () => { { surveyCode: survey2.code, entityCode: kiribatiCountry.code, + outdated: true, data_time: new Date(), answers: { question_7_test: 'question_7_test answer', @@ -161,6 +162,16 @@ describe('exportSurveyResponses(): GET export/surveysResponses', () => { expectAccessibleExportDataHeaderRow(exportData); } }); + + it('ignores outdated survey responses', async () => { + await app.grantAccess(DEFAULT_POLICY); + await app.get( + `export/surveyResponses?surveyCodes=${survey2.code}&countryCode=${kiribatiCountry.code}`, + ); + + // Should not fetch outdated survey response so there should be no export data + expect(xlsx.utils.aoa_to_sheet).not.to.have.been.called; + }); }); describe('Should not allow exporting a survey if users do not have Tupaia Admin Panel and survey permission group access to the corresponding countries', () => { diff --git a/packages/central-server/src/tests/apiV2/export/exportSurveys/cellBuilders/EntityConfigCellBuilder.test.js b/packages/central-server/src/tests/apiV2/export/exportSurveys/cellBuilders/EntityConfigCellBuilder.test.js index f2f95e5fad..22c4f5ab4f 100644 --- a/packages/central-server/src/tests/apiV2/export/exportSurveys/cellBuilders/EntityConfigCellBuilder.test.js +++ b/packages/central-server/src/tests/apiV2/export/exportSurveys/cellBuilders/EntityConfigCellBuilder.test.js @@ -30,6 +30,12 @@ const QUESTIONS = [ code: 'entity_question', type: ANSWER_TYPES.ENTITY, }, + { + id: 'q5', + code: 'question_5_code', + validationCriteria: 'mandatory: true', + type: ANSWER_TYPES.CODE_GENERATOR, + }, ]; const assertCanProcessAndBuildEntity = config => @@ -58,7 +64,7 @@ describe('EntityConfigCellBuilder', () => { it('supports validating presence of fields.name, fields.code AND fields.type when createNew is true', async () => { await assertCanProcessAndBuildEntity( - 'createNew: Yes\r\nfields.name: question_3_code\r\nfields.code: question_3_code\r\nfields.type: school', + 'createNew: Yes\r\nfields.name: question_3_code\r\nfields.code: question_5_code\r\nfields.type: school', ); }); diff --git a/packages/central-server/src/tests/apiV2/import/importSurveyResponses/testFunctionality.js b/packages/central-server/src/tests/apiV2/import/importSurveyResponses/testFunctionality.js index 21d4cbe610..4a97a6e1b3 100644 --- a/packages/central-server/src/tests/apiV2/import/importSurveyResponses/testFunctionality.js +++ b/packages/central-server/src/tests/apiV2/import/importSurveyResponses/testFunctionality.js @@ -110,8 +110,13 @@ export const testFunctionality = async () => { await app.grantFullAccess(); await findOrCreateDummyCountryEntity(models, { code: 'DL', name: 'Demo Land' }); - const entities = ['DL_1', 'DL_5', 'DL_7', 'DL_9'].map(code => ({ - code, + const entities = [ + { code: 'DL_1', name: 'Port Douglas' }, + { code: 'DL_5', name: 'Hawthorn East' }, + { code: 'DL_7', name: 'Lake Charm' }, + { code: 'DL_9', name: 'Thornbury' }, + ].map(entity => ({ + ...entity, country_code: 'DL', })); await findOrCreateRecords(models, { entity: entities }); diff --git a/packages/central-server/src/tests/apiV2/import/importSurveyResponses/testGeneral.js b/packages/central-server/src/tests/apiV2/import/importSurveyResponses/testGeneral.js index 012178ae20..e6845bfb38 100644 --- a/packages/central-server/src/tests/apiV2/import/importSurveyResponses/testGeneral.js +++ b/packages/central-server/src/tests/apiV2/import/importSurveyResponses/testGeneral.js @@ -29,8 +29,13 @@ export const testGeneral = async () => { await app.grantFullAccess(); await findOrCreateDummyCountryEntity(models, { code: 'DL', name: 'Demo Land' }); - const entities = ['DL_1', 'DL_5', 'DL_7', 'DL_9'].map(code => ({ - code, + const entities = [ + { code: 'DL_1', name: 'Port Douglas' }, + { code: 'DL_5', name: 'Hawthorn East' }, + { code: 'DL_7', name: 'Lake Charm' }, + { code: 'DL_9', name: 'Thornbury' }, + ].map(entity => ({ + ...entity, country_code: 'DL', })); await findOrCreateRecords(models, { entity: entities }); diff --git a/packages/central-server/src/tests/apiV2/import/importSurveyResponses/testOutdatedStatusUpdate.js b/packages/central-server/src/tests/apiV2/import/importSurveyResponses/testOutdatedStatusUpdate.js index e3d886f524..6a35f1e83e 100644 --- a/packages/central-server/src/tests/apiV2/import/importSurveyResponses/testOutdatedStatusUpdate.js +++ b/packages/central-server/src/tests/apiV2/import/importSurveyResponses/testOutdatedStatusUpdate.js @@ -79,8 +79,8 @@ export const testOutdatedStatusUpdate = () => { before(async () => { await app.grantFullAccess(); await buildAndInsertSurveys(models, Object.values(SURVEYS)); - await findOrCreateDummyCountryEntity(models, { code: 'TO' }); - await findOrCreateDummyCountryEntity(models, { code: 'VU' }); + await findOrCreateDummyCountryEntity(models, { code: 'TO', name: 'Tonga' }); + await findOrCreateDummyCountryEntity(models, { code: 'VU', name: 'Vanuatu' }); }); beforeEach(async () => { diff --git a/packages/central-server/src/tests/apiV2/import/importSurveyResponses/testPermissions.js b/packages/central-server/src/tests/apiV2/import/importSurveyResponses/testPermissions.js index 7b3ac505bc..a2324ea3a5 100644 --- a/packages/central-server/src/tests/apiV2/import/importSurveyResponses/testPermissions.js +++ b/packages/central-server/src/tests/apiV2/import/importSurveyResponses/testPermissions.js @@ -96,25 +96,42 @@ export const testPermissions = async () => { const entity = await findOrCreateDummyRecord(models.entity, { code: 'DL_7', country_code: demoLand.code, + name: 'Lake Charm', + }); + await findOrCreateDummyRecord(models.entity, { + code: 'DL_9', + country_code: demoLand.code, + name: 'Thornbury', + }); + await findOrCreateDummyRecord(models.entity, { + code: 'DL_10', + country_code: demoLand.code, + name: 'Traralgon', + }); + await findOrCreateDummyRecord(models.entity, { + code: 'DL_11', + country_code: demoLand.code, + name: 'National Medical Warehouse', }); - await findOrCreateDummyRecord(models.entity, { code: 'DL_9', country_code: demoLand.code }); - await findOrCreateDummyRecord(models.entity, { code: 'DL_10', country_code: demoLand.code }); - await findOrCreateDummyRecord(models.entity, { code: 'DL_11', country_code: demoLand.code }); await findOrCreateDummyRecord(models.entity, { code: 'KI_111_test', country_code: kiribatiCountry.code, + name: 'Test 1', }); await findOrCreateDummyRecord(models.entity, { code: 'KI_222_test', country_code: kiribatiCountry.code, + name: 'Test 2', }); await findOrCreateDummyRecord(models.entity, { code: 'KI_333_test', country_code: kiribatiCountry.code, + name: 'Test 3', }); await findOrCreateDummyRecord(models.entity, { code: 'KI_444_test', country_code: kiribatiCountry.code, + name: 'Test 4', }); const userId = 'user_00000000000000_test'; await models.user.updateOrCreate( diff --git a/packages/central-server/src/tests/apiV2/import/importSurveyResponses/testValidation.js b/packages/central-server/src/tests/apiV2/import/importSurveyResponses/testValidation.js index a084ca911a..31d3ee84d2 100644 --- a/packages/central-server/src/tests/apiV2/import/importSurveyResponses/testValidation.js +++ b/packages/central-server/src/tests/apiV2/import/importSurveyResponses/testValidation.js @@ -24,7 +24,13 @@ export const testValidation = async () => { await buildAndInsertSurveys(models, [VALIDATION_SURVEY]); await findOrCreateDummyCountryEntity(models, { code: 'DL', name: 'Demo Land' }); await findOrCreateRecords(models, { - entity: ['DL_7', 'DL_9', 'DL_10', 'DL_11'].map(code => ({ code, country_code: 'DL' })), + entity: [ + { code: 'DL_1', name: 'Port Douglas' }, + { code: 'DL_7', name: 'Lake Charm' }, + { code: 'DL_9', name: 'Thornbury' }, + { code: 'DL_10', name: 'Traralgon' }, + { code: 'DL_11', name: 'National Medical Warehouse' }, + ].map(entity => ({ ...entity, country_code: 'DL' })), }); }); @@ -52,6 +58,16 @@ export const testValidation = async () => { 'nonExistentQuestionId.xlsx', /No question with id/, ], + [ + 'entity code and name mismatch', + 'mismatchEntityNameAndCode.xlsx', + /Entity code and name don\'t match: Thornbury and Lake Charm/, + ], + [ + 'invalid entity code', + 'invalidEntity.xlsx', + /Entity code does match any existing entity: DL_15/, + ], ]; testData.forEach(([description, file, expectedError]) => { diff --git a/packages/central-server/src/tests/apiV2/resubmitSurveyResponse.test.js b/packages/central-server/src/tests/apiV2/resubmitSurveyResponse.test.js index cd6e72dac5..78fbeaec49 100644 --- a/packages/central-server/src/tests/apiV2/resubmitSurveyResponse.test.js +++ b/packages/central-server/src/tests/apiV2/resubmitSurveyResponse.test.js @@ -50,7 +50,7 @@ describe('resubmit surveyResponse endpoint', () => { app.revokeAccess(); }); - it('Should throw error when answers contain an invalid question code', async () => { + it('Should return a successful response, and outdate the original survey response', async () => { const [surveyResponse] = await buildAndInsertSurveyResponses(models, [ { surveyCode: 'TEST_SURVEY_RESP_CRUD', @@ -60,124 +60,25 @@ describe('resubmit surveyResponse endpoint', () => { }, ]); - const response = await app.post(`surveyResponse/${surveyResponse.surveyResponse.id}/resubmit`, { - body: { - data_time: '2020-02-02T09:00:00', - answers: { - TEST_INV: '1236', - }, - }, - }); - - expect(response.body).to.have.keys('error'); - }); - - it('Should return a successful response', async () => { - const [surveyResponse] = await buildAndInsertSurveyResponses(models, [ + const response = await app.post( + `surveyResponses/${surveyResponse.surveyResponse.id}/resubmit`, { - surveyCode: 'TEST_SURVEY_RESP_CRUD', - entityCode: ENTITY_ID, - data_time: '2020-01-31T09:00:00', - answers: { [questionCode(1)]: '123' }, - }, - ]); - - const response = await app.post(`surveyResponse/${surveyResponse.surveyResponse.id}/resubmit`, { - body: { - data_time: '2020-02-02T09:00:00', - answers: { - [questionCode(1)]: '1236', + body: { + data_time: '2020-02-02T09:00:00', + survey_id: surveyId, + entity_code: ENTITY_ID, + timezone: 'Pacific/Auckland', + start_time: '2020-02-02T09:00:00', + end_time: '2020-02-02T09:10:00', + answers: { + [questionCode(1)]: '1236', + }, }, }, - }); - - expectSuccess(response); - }); - - it('Should update the survey response fields', async () => { - const [surveyResponse] = await buildAndInsertSurveyResponses(models, [ - { - surveyCode: 'TEST_SURVEY_RESP_CRUD', - entityCode: ENTITY_ID, - assessor_name: 'Jane', - answers: { [questionCode(1)]: '123' }, - }, - ]); - - const response = await app.post(`surveyResponse/${surveyResponse.surveyResponse.id}/resubmit`, { - body: { - assessor_name: 'Bob', - }, - }); - - expectSuccess(response); - const updatedSurveyResponse = await app.get( - `surveyResponses/${surveyResponse.surveyResponse.id}`, ); - expect(updatedSurveyResponse.body.assessor_name).to.equal('Bob'); - }); - - it('Should update an existing answer', async () => { - const [surveyResponse] = await buildAndInsertSurveyResponses(models, [ - { - surveyCode: 'TEST_SURVEY_RESP_CRUD', - entityCode: ENTITY_ID, - assessor_name: 'Jane', - answers: { [questionCode(1)]: '123' }, - }, - ]); - - const response = await app.post(`surveyResponse/${surveyResponse.surveyResponse.id}/resubmit`, { - body: { - answers: { [questionCode(1)]: 'update test' }, - }, - }); - - expectSuccess(response); - const { body } = await app.get(`surveyResponses/${surveyResponse.surveyResponse.id}/answers`); - expect(body[0].text).to.equal('update test'); - }); - - it('Should create a new answer if doesnt exist', async () => { - const [surveyResponse] = await buildAndInsertSurveyResponses(models, [ - { - surveyCode: 'TEST_SURVEY_RESP_CRUD', - entityCode: ENTITY_ID, - assessor_name: 'Jane', - answers: { [questionCode(1)]: '123' }, - }, - ]); - - const response = await app.post(`surveyResponse/${surveyResponse.surveyResponse.id}/resubmit`, { - body: { - answers: { [questionCode(2)]: 'hello world' }, - }, - }); - - expectSuccess(response); - const { body } = await app.get(`surveyResponses/${surveyResponse.surveyResponse.id}/answers`); - expect(body).to.have.length(2); - expect(body.filter(answer => answer.text === 'hello world')).to.not.be.an('undefined'); - }); - - it('Should delete an answer if it already exists and the update is null', async () => { - const [surveyResponse] = await buildAndInsertSurveyResponses(models, [ - { - surveyCode: 'TEST_SURVEY_RESP_CRUD', - entityCode: ENTITY_ID, - assessor_name: 'Jane', - answers: { [questionCode(1)]: '123' }, - }, - ]); - - const response = await app.post(`surveyResponse/${surveyResponse.surveyResponse.id}/resubmit`, { - body: { - answers: { [questionCode(1)]: null }, - }, - }); expectSuccess(response); - const { body } = await app.get(`surveyResponses/${surveyResponse.surveyResponse.id}/answers`); - expect(body).to.have.length(0); + const originalResponse = await models.surveyResponse.findById(surveyResponse.surveyResponse.id); + expect(originalResponse.outdated).to.equal(true); }); }); diff --git a/packages/central-server/src/tests/apiV2/surveyResponse.test.js b/packages/central-server/src/tests/apiV2/surveyResponse.test.js index 74654e9607..1371db9192 100644 --- a/packages/central-server/src/tests/apiV2/surveyResponse.test.js +++ b/packages/central-server/src/tests/apiV2/surveyResponse.test.js @@ -510,7 +510,7 @@ describe('surveyResponse endpoint', () => { numberOfAnswersInSurveyResponse = await models.answer.count({ survey_response_id: surveyResponseId, }); - response = await app.post(`surveyResponse/${surveyResponseId}/resubmit`, { + response = await app.put(`surveyResponses/${surveyResponseId}`, { body: { entity_id: newEntityId, }, diff --git a/packages/central-server/src/tests/dhis/DhisChangeValidator.test.js b/packages/central-server/src/tests/dhis/DhisChangeValidator.test.js new file mode 100644 index 0000000000..1bb1d65e9c --- /dev/null +++ b/packages/central-server/src/tests/dhis/DhisChangeValidator.test.js @@ -0,0 +1,215 @@ +/** + * Tupaia + * Copyright (c) 2017 - 2024 Beyond Essential Systems Pty Ltd + */ + +import { expect } from 'chai'; +import { + buildAndInsertSurvey, + buildAndInsertSurveyResponses, + findOrCreateDummyCountryEntity, + findOrCreateDummyRecord, + generateId, +} from '@tupaia/database'; +import { getModels, TestableApp } from '../testUtilities'; +import { DhisChangeValidator } from '../../dhis/DhisChangeValidator'; + +const models = getModels(); +describe('DhisChangeValidator', async () => { + const ChangeValidator = new DhisChangeValidator(models); + + let answers; + let surveyResponse; + + before(async () => { + const user = await findOrCreateDummyRecord(models.user, { email: 'test_user@email.com' }); + const { entity: tongaEntity } = await findOrCreateDummyCountryEntity(models, { code: 'TO' }); + const adminPermissionGroup = await findOrCreateDummyRecord(models.permissionGroup, { + name: 'Admin', + }); + const SURVEY = { + code: 'testSurvey', + name: 'Test Survey', + dataGroup: { + code: 'testDataGroup', + service_type: 'dhis', + config: { + dhisInstanceCode: 'test_instance', + }, + }, + questions: [ + { + code: 'question1', + type: 'FreeText', + prompt: 'Question 1', + }, + ], + }; + + await buildAndInsertSurvey(models, SURVEY); + const SURVEY_RESPONSE = { + surveyCode: 'testSurvey', + entityCode: tongaEntity.code, + answers: { + question1: 'answer1', + }, + }; + + await models.userEntityPermission.create({ + user_id: user.id, + entity_id: tongaEntity.id, + permission_group_id: adminPermissionGroup.id, + }); + + const [builtSurveyResponse] = await buildAndInsertSurveyResponses( + models, + [SURVEY_RESPONSE], + user, + ); + answers = await Promise.all(builtSurveyResponse.answers.map(answer => answer.getData())); + surveyResponse = await builtSurveyResponse.surveyResponse.getData(); + }); + + describe('getOutdatedAnswersAndSurveyResponses', () => { + it('should return outdated survey responses and associated answers as deletes', async () => { + await models.surveyResponse.updateById(surveyResponse.id, { outdated: true }); + const changes = [ + { + record_id: surveyResponse.id, + record_type: 'survey_response', + type: 'update', + new_record: { + ...surveyResponse, + outdated: true, + }, + old_record: surveyResponse, + }, + ]; + const result = await ChangeValidator.getOutdatedAnswersAndSurveyResponses(changes); + expect(result.length).to.equal(2); + expect(result).to.deep.equal([ + { + record_id: surveyResponse.id, + record_type: 'survey_response', + type: 'delete', + new_record: null, + old_record: surveyResponse, + }, + ...answers.map(answer => ({ + record_id: answer.id, + record_type: 'answer', + type: 'delete', + new_record: null, + old_record: answer, + })), + ]); + }); + + it('should not include any answers already being deleted in the changes list', async () => { + await models.surveyResponse.updateById(surveyResponse.id, { outdated: true }); + const changes = [ + { + record_id: surveyResponse.id, + record_type: 'survey_response', + type: 'update', + new_record: { + ...surveyResponse, + outdated: true, + }, + old_record: surveyResponse, + }, + { + record_id: answers[0].id, + record_type: 'answer', + type: 'delete', + old_record: answers[0], + }, + ]; + const result = await ChangeValidator.getOutdatedAnswersAndSurveyResponses(changes); + expect(result.length).to.equal(1); + expect(result).to.deep.equal([ + { + record_id: surveyResponse.id, + record_type: 'survey_response', + type: 'delete', + new_record: null, + old_record: surveyResponse, + }, + ]); + }); + + it('should not include any already outdated survey responses being updated', async () => { + await models.surveyResponse.updateById(surveyResponse.id, { outdated: true }); + const changes = [ + { + record_id: surveyResponse.id, + record_type: 'survey_response', + type: 'update', + new_record: { + ...surveyResponse, + outdated: true, + data_time: new Date('2024-07-24').toISOString(), + }, + old_record: { + ...surveyResponse, + outdated: true, + }, + }, + ]; + const result = await ChangeValidator.getOutdatedAnswersAndSurveyResponses(changes); + + expect(result.length).to.equal(0); + }); + }); + + describe('getAnswersToUpdate', () => { + it('should return answers associated with any survey responses that are changing from outdated to not outdated', async () => { + const changes = [ + { + record_id: surveyResponse.id, + record_type: 'survey_response', + type: 'update', + new_record: surveyResponse, + old_record: { + ...surveyResponse, + outdated: true, + }, + }, + ]; + const result = await ChangeValidator.getAnswersToUpdate(changes); + expect(result.length).to.equal(1); + expect(result).to.deep.equal( + answers.map(answer => ({ + record_id: answer.id, + record_type: 'answer', + type: 'update', + new_record: answer, + old_record: answer, + })), + ); + }); + + it('should not include any answers already being updated in the changes list', async () => { + const changes = [ + { + record_id: surveyResponse.id, + record_type: 'survey_response', + type: 'update', + new_record: surveyResponse, + old_record: { + ...surveyResponse, + outdated: true, + }, + }, + { + record_id: answers[0].id, + record_type: 'answer', + type: 'update', + old_record: answers[0], + }, + ]; + const result = await ChangeValidator.getAnswersToUpdate(changes); + expect(result.length).to.equal(0); + }); + }); +}); diff --git a/packages/central-server/src/tests/testData/surveyResponses/functionality/nonPeriodicBaseline.xlsx b/packages/central-server/src/tests/testData/surveyResponses/functionality/nonPeriodicBaseline.xlsx index 80e8344a666d7251e1d2875a672b0d7bac78ea24..735e15dfaa2a3999f498a98711b5b3d3e6dc5c70 100644 GIT binary patch delta 8127 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 delta 7612 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 Bb_f6f diff --git a/packages/central-server/src/tests/testData/surveyResponses/functionality/periodicBaseline.xlsx b/packages/central-server/src/tests/testData/surveyResponses/functionality/periodicBaseline.xlsx index 44ff9f505ee44ab45289dfd507edd9c809589e8a..88476306b625e1762834c678ead8faccdf38c94d 100644 GIT binary patch delta 9468 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

588A`=|!D2?EY3HN5Nz?l!7l@q&By zP`G18Gj0LT)rb*~z`maA^2dilL;or2$(;#LecAl1Ag(}gRF{p8TeOg_&7AW_txdld z_>^~1@|&Mp%wkRlp#T=k?B_Ak*`}dYo+?3M3a<~H-$VPZWUQh#`E0`%aY6o9VQcqQ z^N8O&hIwK@HA|=2pr9wBQ7eE?jB-g4GToXDDw?dBH+SYQ1gB(rrdsT6Ttk!INm6-<;1&if!h^JQ zhD;>FkRsf6+1GCAvDi>N6C8?(%iJ6Ok6xKc(DGt-@-Z|!l|zASZL4?sL}Tu5iww>Oa)5cZtZ5vt zUvuBfxJQ)zj${34+2dO}ya|JqJx#7tx{HMAJ*39DaVUE+7?l)=#ACa;Yh|*q2Fcsm zDW+x(nQb-7eSS!ChLoxGgLfnk>2?0Hf=Iir=7QuCwS(z~Y1NcjnGilD!@h%IhPWF3|el(6YhLxzQyZHDpi*liDQzGMEukZ zD)2<{a}GXye%VPXzN_6)uu+&h%ZM)7;hNiJkmdsj(@#oM*~0G>Z2c;R0WECc81 z=I|M6Y)cbMsKSP8aZ#NGzJe?BEqr)2g$)#Z^lXSsQ`81D!X+?vg83Q9!Et=%iROU~ zFBAk`e~@3%OR=4e>U#;gG*Zx6(@Lii!#?MsIWJZn8%-k4%tzS;zcMKP)bvClxL^2j zVJl7!?|Br$v2xPp3O0UzFB)v%wI3eUvoR(9rw6api1Hl2@=&$yk3ILG?LBVgU)5py zTJ|T6PIBtEe&Knn-O+h4lDMJWNBv(@7ih0-CP80nU19~kM2s77v|B53sHIm@X0Ea5 zRa%k{Z%_XG)%R(z?Qox_NrJ~fA4YnNG*mR)7u;gwTZDG=s6+_UG_tmT$rJT}T8p1-T}nSxDE;DS|rWA&PRe z9%uLIatsXfFSrZl9N7xrz?~0yq9g;GfU-I8BG2hh#r4-p17v7*WXoELe!c%N&t&u2 z+vZb0jh#8S)56~7&tX*=m{r%PpT`~({s|PxB0V~Ef1`l9>J66zoqcd$a_n@I0-(>?zK ziUPd4Q*GFe+0A1in{{#HnEqGfL%@BBE=C3k=3)yEu)4eHi`%7zv@@Oz%)}3@&pS={ zBU6;SQVt9A#5+VkM!uqU2H?B6(>4B%rNU3(mwjKH-ofd)5Am{E>X;&|>AbdS`<{O8 zdn=C4x>Ts8LGT1y^0Xcpw(T0G9~-Zjr@;$$nDJS0tt)gkrRH1v`>y+n+ZoAM3VL=T zqZ|nw@Wp1c_LscDg7@OZHWS=gf0^zQZ#X;u5#=~|iz@fwQ%LV;IhCBC5(M*2^MF23 zVTedHRfM=s%1`RCi)(G9QLHli_4HApmvuoGA9Krxd2JuAC*9~y8TUc5n0fhigzR*p z!oir9GJCJx*~XRfxjqa>2cTN?rg2Ue*Rn70v54-5I%P3G_4qH=*k*~FjbO%81> z58dwbrsQ&~Rtp=YeF__!T@t5eJ6r?jrc4e+ij4&O2n2SN{R5ma<-KdVEuU;L7CTTs zY40!wl~0of9WDqX6C7*^6ED!^$aAJ?IXV~CJUUQi^0|r$_nN66qdJ~R9?N>PIXKSvZ-&X#h0NZb6+79`ZqBdSh?{^S|pE3W% zncyg#TEH95S|Ot}G+=EL&@2aDp!|-w{>Gpw2`^(6&_&apWCchGgM(?8WjtcvCD@aE zT5=d9y6D~E6^x}Zvvddjo9FMMm5}@XFVRTk=d&sc)usk0+i_~$*4N9--~{yC640hf z@5x%&-%8o2rD7ui$AneXclX@|2< zvZz9dSl4<)$Ll{Sgr#Iz>Jb{53tl$<_Bw>>+@^vQ)FJf-B?s5oS9|D%|1GO?~O1m(-4aQsKj}fvDcbQ z+E|m52=j*7?Tg*zbYrDY+h*lT>xp~Jc<`K3v$JR?i|cDDqtyRhXabgF4)yVC07{%AI@sUF^}VMoP>H0t8GzpM%@*CX zfl|EoO8B#iR4cungsi{Y9gEzkbO@*0rT_anLq8`sL}B^}UFy$R`}AczV+SIwzD7%W zo`sSkJqOX%wH9i9GM6t5A$`L!xoH9}kT@7Vj*UK|lGLOI-9e4mIh5*_$cVH2+HpZY zD`P=$E?6(&L@sLkK3ZC>$B8^?=uUtuzh5m(f#U!d?JDZ(6y5t5on`+My8JWy-Z6tT zQyd<*J2uFeRx&1c1Is>*aJ+QWac-2+$<;%;BfVAMuM~lV6XugePF|LTVIXPS zRMmA7XLT5}Oa54~_%+Yd684i}H5ld}c@z!+A>_b7o<*?r40%Ij*Bc_qz+7zJ&Q5V} zxO^kPz&BOiyd5(T8D*Nyo-A71>u8nFKD#NZyn>XYzxqc9JxvW|!nFp|t;W260EJJX~)!WT@EAJ{Uv^DoJt0WvX1kG&0is)wv0jfw{b;v4G zFVZBHq6HtXZ?3XZqCLHTEPBL5Z=RW>yq&smE+QpjIdo*ZjiBJ_@xKx zf3skBTzo5hwvt!iyRQnhOk6w*CU+3fjO&IqxznV1A};*sAn=l(wN&w6iMOF2c0Z z_(;ebK+Y5wbAO)M=dfKjLUvMBmRc2xViDY>0!dhK3loQXOmV@}R;!h=Ja=5eotEqt zSE8(|pPxd?X;qZRJSGO2-&iRrXpC<&$T>x%7)=H|{#+aalXg?peCeHJmh-NBx(WDR zPH)DSIJ)EiCG+Q2i6Sl04Kl)|bjK}0ryUI^=q0l8h4A`A5*2|Q+eg};w$>rzzbAT4 z%qA7mcLFsg1$2N%*^kuK{k)e>$%`Q@RkswSn2<*jP|=P0%8 z@K8e0K$s=`cTeMnudx4?iHRB6U>gIu4B#Nd9B9ZN=bulq-e61FsLo6Y0}jyaS6zFH zKWY9@P;WMR!ZauFE@tY91FXp*TrJ-_kIH9eay4{^s>^t zj2dp2V^VKPrH`O1=zQ{V$Ymkp9y47wRd;;BK))V_S{T$r#+p`}hI&CSfC+>CJub(o zD$T53UdGIWe4fUWi%r2I1{~m808!v@YRfn>a0$+k%DKQ$cZf+zjBL**Tb|OaT%G|* zXQMUkOiLjW(6AG{120mCvjwdreG#>>b-iesby*7pRlj%F?Umz3ee5y2(IE-jWBO)5ntkRt0yWOr`@L+KGjGk5 za7Vg{mgSj(V*wrDAhap|m|8PWojcB}w072@eFm9%`N04BZzzSbRsM2T`Q|EjxJDE< z3K%d%((%nz5$6#vh=u;TQ!U{QqbR2(bWv<3`t${*TxlbY^)1#sxQ%DEwO#KM*2z)2 zyFYP@fEN^wZrA1%qe7~DGTTgxsU>X#EY>-3G4C@fF{!B#^dwHnK7NzOX?-{aHe>b- z^${?}U?*^CaJB*&$F!FK)t0M6s)y2Wq~LMa)E<`Veqm4^(354+-jC*T;sAxEW`$8b zE+#wSmA~oY<5uBF7l@`#mtwW01wr7Yc*6i!tls*BFfUf>}<5_6%PUfs z9rD7*O#0ty&i^}(g#*FFA%U3jV*&qPUH88)>4ALae^2_KR1*OJK>zRHTT7W7GQm$l z`k!ze6#&5dUk1`t5Ev=~2$=vD>3?Fax8f4;e;E_c7zY7Eac$>xl>pJFybP0;EPVfK#)*vf&caW99sasw7 U-P?Qs061@V>)Y?r^8eHOKQ=>{SpWb4 delta 9200 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 H>+6328K(Os diff --git a/packages/central-server/src/tests/testData/surveyResponses/functionality/periodicUpdates.xlsx b/packages/central-server/src/tests/testData/surveyResponses/functionality/periodicUpdates.xlsx index d5c90b96fa1bda7edb1d6c0013b8726ea24471fe..58e1debf9a10c3fc98d3c0fe4f2096c8aa64f3b0 100644 GIT binary patch delta 10038 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 dmQL;m0B8UJfCgZ4oNf8~5de^Y1ON~K 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 G0Qi4>65%@l literal 0 HcmV?d00001 diff --git a/packages/central-server/src/tests/testData/surveyResponses/validation/mismatchEntityNameAndCode.xlsx b/packages/central-server/src/tests/testData/surveyResponses/validation/mismatchEntityNameAndCode.xlsx new file mode 100644 index 0000000000000000000000000000000000000000..d8d3589e3c6ea621841274a8a45243b6767b605b GIT binary patch literal 9598 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 L-a9bI5&-x=?!^4~ literal 0 HcmV?d00001 diff --git a/packages/database/src/testUtilities/buildAndInsertSurveyResponses.js b/packages/database/src/testUtilities/buildAndInsertSurveyResponses.js index bc179dc661..3f12dfeea6 100644 --- a/packages/database/src/testUtilities/buildAndInsertSurveyResponses.js +++ b/packages/database/src/testUtilities/buildAndInsertSurveyResponses.js @@ -65,8 +65,8 @@ const buildAndInsertSurveyResponse = async ( * ]); * ``` */ -export const buildAndInsertSurveyResponses = async (models, surveyResponses) => { - const user = await upsertDummyRecord(models.user); +export const buildAndInsertSurveyResponses = async (models, surveyResponses, userData = {}) => { + const user = await upsertDummyRecord(models.user, userData); const builtResponses = []; for (let i = 0; i < surveyResponses.length; i++) { const surveyResponse = surveyResponses[i]; diff --git a/packages/datatrak-web-server/package.json b/packages/datatrak-web-server/package.json index 043f3eb21f..c5e62eb60a 100644 --- a/packages/datatrak-web-server/package.json +++ b/packages/datatrak-web-server/package.json @@ -33,6 +33,8 @@ "@tupaia/types": "workspace:*", "@tupaia/utils": "workspace:*", "camelcase-keys": "^6.2.2", + "date-fns": "^2.29.2", + "date-fns-tz": "^2.0.1", "express": "^4.19.2", "lodash.groupby": "^4.6.0", "lodash.keyby": "^4.6.0", diff --git a/packages/datatrak-web-server/src/__tests__/processSurveyResponse.test.ts b/packages/datatrak-web-server/src/__tests__/processSurveyResponse.test.ts index 0ef34a55b8..f69bdc3e4b 100644 --- a/packages/datatrak-web-server/src/__tests__/processSurveyResponse.test.ts +++ b/packages/datatrak-web-server/src/__tests__/processSurveyResponse.test.ts @@ -50,7 +50,7 @@ describe('processSurveyResponse', () => { surveyId: 'theSurveyId', countryId: 'theCountryId', startTime: 'theStartTime', - timezone: 'theTimezone', + timezone: 'Pacific/Auckland', }; const processedResponseData = { @@ -61,7 +61,7 @@ describe('processSurveyResponse', () => { entity_id: 'theCountryId', end_time: timestamp, timestamp: timestamp, - timezone: 'theTimezone', + timezone: 'Pacific/Auckland', options_created: [], entities_upserted: [], qr_codes_to_create: [], @@ -148,13 +148,13 @@ describe('processSurveyResponse', () => { }, ], answers: { - question1: '2022-01-01', + question1: '2022-01-01T00:00', }, }); expect(result).toEqual({ ...processedResponseData, - data_time: new Date('2022-01-01').toISOString(), + data_time: '2022-01-01T00:00+13:00', answers: [], }); }); @@ -172,13 +172,13 @@ describe('processSurveyResponse', () => { }, ], answers: { - question1: '2022-01-01', + question1: '2022-01-01T00:00', }, }); expect(result).toEqual({ ...processedResponseData, - data_time: new Date('2022-01-01').toISOString(), + data_time: '2022-01-01T00:00+13:00', answers: [], }); }); @@ -544,7 +544,7 @@ describe('processSurveyResponse', () => { ], answers: { question1: { - value: 'theEncodedFile', + value: 'data://theEncodedFile', name: 'theFileName', }, }, @@ -557,11 +557,101 @@ describe('processSurveyResponse', () => { question_id: 'question1', type: QuestionType.File, body: { - data: 'theEncodedFile', + data: 'data://theEncodedFile', uniqueFileName: getUniqueSurveyQuestionFileName('theFileName'), }, }, ], }); }); + + it('should handle when question type is File and the file is not an encoded file', async () => { + const result = await processSurveyResponse(mockModels, { + ...responseData, + questions: [ + { + questionId: 'question1', + type: QuestionType.File, + componentNumber: 1, + text: 'question1', + screenId: 'screen1', + }, + ], + answers: { + question1: { + value: 'filename.png', + name: 'theFileName', + }, + }, + }); + + expect(result).toEqual({ + ...processedResponseData, + answers: [ + { + question_id: 'question1', + type: QuestionType.File, + body: 'filename.png', + }, + ], + }); + }); + + it('should add the timezone offset when question type is Date', async () => { + const result = await processSurveyResponse(mockModels, { + ...responseData, + questions: [ + { + questionId: 'question1', + type: QuestionType.Date, + componentNumber: 1, + text: 'question1', + screenId: 'screen1', + }, + ], + answers: { + question1: '2022-01-01T00:00', + }, + }); + + expect(result).toEqual({ + ...processedResponseData, + answers: [ + { + question_id: 'question1', + type: QuestionType.Date, + body: '2022-01-01T00:00+13:00', + }, + ], + }); + }); + + it('should add the timezone offset when question type is DateTime', async () => { + const result = await processSurveyResponse(mockModels, { + ...responseData, + questions: [ + { + questionId: 'question1', + type: QuestionType.DateTime, + componentNumber: 1, + text: 'question1', + screenId: 'screen1', + }, + ], + answers: { + question1: '2022-01-01T00:00', + }, + }); + + expect(result).toEqual({ + ...processedResponseData, + answers: [ + { + question_id: 'question1', + type: QuestionType.DateTime, + body: '2022-01-01T00:00+13:00', + }, + ], + }); + }); }); diff --git a/packages/datatrak-web-server/src/app/createApp.ts b/packages/datatrak-web-server/src/app/createApp.ts index fc9ff4d249..e25b76bb3a 100644 --- a/packages/datatrak-web-server/src/app/createApp.ts +++ b/packages/datatrak-web-server/src/app/createApp.ts @@ -45,6 +45,8 @@ import { EntitiesRequest, GenerateLoginTokenRoute, GenerateLoginTokenRequest, + ResubmitSurveyResponseRequest, + ResubmitSurveyResponseRoute, } from '../routes'; import { attachAccessPolicy } from './middleware'; @@ -65,6 +67,10 @@ export async function createApp() { 'submitSurveyResponse', handleWith(SubmitSurveyResponseRoute), ) + .post( + 'resubmitSurveyResponse/:originalSurveyResponseId', + handleWith(ResubmitSurveyResponseRoute), + ) .post('generateLoginToken', handleWith(GenerateLoginTokenRoute)) .get('getUser', handleWith(UserRoute)) .get('entity/:entityCode', handleWith(SingleEntityRoute)) diff --git a/packages/datatrak-web-server/src/routes/SingleSurveyResponseRoute.ts b/packages/datatrak-web-server/src/routes/SingleSurveyResponseRoute.ts index 3f0b348023..53e273853c 100644 --- a/packages/datatrak-web-server/src/routes/SingleSurveyResponseRoute.ts +++ b/packages/datatrak-web-server/src/routes/SingleSurveyResponseRoute.ts @@ -32,6 +32,7 @@ const DEFAULT_FIELDS = [ 'user_id', 'country.code', 'survey.permission_group_id', + 'timezone', ]; const BES_ADMIN_PERMISSION_GROUP = 'BES Admin'; @@ -99,6 +100,7 @@ export class SingleSurveyResponseRoute extends Route, answer: { question_id: string; text: string }) => ({ @@ -109,6 +111,6 @@ export class SingleSurveyResponseRoute extends Route; + +export class ResubmitSurveyResponseRoute extends Route { + public async buildResponse() { + const surveyResponseData = this.req.body; + const { central: centralApi } = this.req.ctx.services; + const { session, models, params } = this.req; + const { originalSurveyResponseId } = params; + + const { qr_codes_to_create, recent_entities, ...processedResponse } = + await processSurveyResponse(models, surveyResponseData); + + await centralApi.resubmitSurveyResponse(originalSurveyResponseId, processedResponse); + + // If the user is logged in, add the entities they answered to their recent entities list + if (!!session && processedResponse.user_id) { + const { user_id: userId } = processedResponse; + // add these after the survey response has been submitted because we want to be able to add newly created entities to the recent entities list + await addRecentEntities(models, userId, recent_entities); + } + + return { + qrCodeEntitiesCreated: qr_codes_to_create || [], + }; + } +} diff --git a/packages/datatrak-web-server/src/routes/SubmitSurveyReponse/index.ts b/packages/datatrak-web-server/src/routes/SubmitSurveyReponse/index.ts index a4dfe5d7a1..7be4fa0082 100644 --- a/packages/datatrak-web-server/src/routes/SubmitSurveyReponse/index.ts +++ b/packages/datatrak-web-server/src/routes/SubmitSurveyReponse/index.ts @@ -7,3 +7,7 @@ export { SubmitSurveyResponseRequest, SubmitSurveyResponseRoute, } from './SubmitSurveyResponseRoute'; +export { + ResubmitSurveyResponseRequest, + ResubmitSurveyResponseRoute, +} from './ResubmitSurveyResponseRoute'; diff --git a/packages/datatrak-web-server/src/routes/SubmitSurveyReponse/processSurveyResponse.ts b/packages/datatrak-web-server/src/routes/SubmitSurveyReponse/processSurveyResponse.ts index 27ad68033b..f8da4959ca 100644 --- a/packages/datatrak-web-server/src/routes/SubmitSurveyReponse/processSurveyResponse.ts +++ b/packages/datatrak-web-server/src/routes/SubmitSurveyReponse/processSurveyResponse.ts @@ -2,8 +2,13 @@ * Tupaia * Copyright (c) 2017 - 2023 Beyond Essential Systems Pty Ltd */ -import { getUniqueSurveyQuestionFileName } from '@tupaia/utils'; import { + getUniqueSurveyQuestionFileName, + formatDateInTimezone, + getOffsetForTimezone, +} from '@tupaia/utils'; +import { + DatatrakWebResubmitSurveyResponseRequest, DatatrakWebSubmitSurveyResponseRequest, Entity, MeditrakSurveyResponseRequest, @@ -13,7 +18,9 @@ import { import { DatatrakWebServerModelRegistry } from '../../types'; import { buildUpsertEntity } from './buildUpsertEntity'; -type SurveyRequestT = DatatrakWebSubmitSurveyResponseRequest.ReqBody; +type SurveyRequestT = + | DatatrakWebSubmitSurveyResponseRequest.ReqBody + | DatatrakWebResubmitSurveyResponseRequest.ReqBody; type CentralServerSurveyResponseT = MeditrakSurveyResponseRequest & { qr_codes_to_create?: Entity[]; recent_entities: string[]; @@ -31,6 +38,11 @@ export const isUpsertEntityQuestion = (config?: SurveyScreenComponentConfig) => return config.entity.fields && Object.keys(config.entity.fields).length > 0; }; +const addTimezoneToDateString = (dateString: string, timezone: string) => { + const timezoneOffset = getOffsetForTimezone(timezone, new Date(dateString)); + return `${dateString}${timezoneOffset}`; +}; + // Process the survey response data into the format expected by the endpoint export const processSurveyResponse = async ( models: DatatrakWebServerModelRegistry, @@ -44,6 +56,7 @@ export const processSurveyResponse = async ( startTime, userId, timezone, + dataTime, } = surveyResponseData; const today = new Date(); @@ -55,7 +68,7 @@ export const processSurveyResponse = async ( start_time: startTime, entity_id: countryId, end_time: timestamp, - data_time: timestamp, + data_time: dataTime ? addTimezoneToDateString(dataTime, timezone) : timestamp, timestamp, timezone, entities_upserted: [], @@ -64,6 +77,10 @@ export const processSurveyResponse = async ( options_created: [], answers: [], }; + // if there is an entityId in the survey response data, add it to the survey response. This will happen in cases of resubmission + if ('entityId' in surveyResponseData && surveyResponseData.entityId) { + surveyResponse.entity_id = surveyResponseData.entityId; + } // Process answers and save the response in the database const answersToSubmit = [] as Record[]; @@ -113,11 +130,22 @@ export const processSurveyResponse = async ( // Handle special question types switch (type) { - // format dates to be ISO strings + // Add the timezone offset to the date string so it saves in the correct timezone case QuestionType.SubmissionDate: case QuestionType.DateOfData: { - const date = new Date(answer as string); - surveyResponse.data_time = date.toISOString(); + surveyResponse.data_time = addTimezoneToDateString(answer as string, timezone); + break; + } + + case QuestionType.Date: + case QuestionType.DateTime: { + // Add the timezone offset to the date string so it saves in the correct timezone + if (answer) { + answersToSubmit.push({ + ...answerObject, + body: addTimezoneToDateString(answer as string, timezone), + }); + } break; } @@ -129,6 +157,15 @@ export const processSurveyResponse = async ( } case QuestionType.File: { const { name, value } = answer as FileUploadAnswerT; + const isBase64 = value.startsWith('data:'); + // if the file is not base64 encoded, save the file as it is, as this means it's a file that was uploaded already, and this is a resubmission + if (!isBase64) { + answersToSubmit.push({ + ...answerObject, + body: value, + }); + break; + } answersToSubmit.push({ ...answerObject, body: { diff --git a/packages/datatrak-web-server/src/routes/index.ts b/packages/datatrak-web-server/src/routes/index.ts index 01be0e48d2..efad4a0641 100644 --- a/packages/datatrak-web-server/src/routes/index.ts +++ b/packages/datatrak-web-server/src/routes/index.ts @@ -11,7 +11,12 @@ export { ProjectsRequest, ProjectsRoute } from './ProjectsRoute'; export { SingleEntityRequest, SingleEntityRoute } from './SingleEntityRoute'; export { EntityDescendantsRequest, EntityDescendantsRoute } from './EntityDescendantsRoute'; export { ProjectRequest, ProjectRoute } from './ProjectRoute'; -export { SubmitSurveyResponseRequest, SubmitSurveyResponseRoute } from './SubmitSurveyReponse'; +export { + SubmitSurveyResponseRequest, + SubmitSurveyResponseRoute, + ResubmitSurveyResponseRequest, + ResubmitSurveyResponseRoute, +} from './SubmitSurveyReponse'; export { RecentSurveysRequest, RecentSurveysRoute } from './RecentSurveysRoute'; export { SingleSurveyResponseRequest, diff --git a/packages/datatrak-web/src/__tests__/mocks/mockData/survey.json b/packages/datatrak-web/src/__tests__/mocks/mockData/survey.json index 42a662a4b1..9506ae8e30 100644 --- a/packages/datatrak-web/src/__tests__/mocks/mockData/survey.json +++ b/packages/datatrak-web/src/__tests__/mocks/mockData/survey.json @@ -47,8 +47,10 @@ "entity": { "type": ["sub_district"], "createNew": false, - "parentId": { - "questionId": "603597d461f76a42af02a366" + "filter": { + "parentId": { + "questionId": "603597d461f76a42af02a366" + } } } }, @@ -73,11 +75,13 @@ "entity": { "type": ["facility"], "createNew": false, - "parentId": { - "questionId": "" - }, - "grandparentId": { - "questionId": "603597d461f76a42af02a366" + "filter": { + "parentId": { + "questionId": "" + }, + "grandparentId": { + "questionId": "603597d461f76a42af02a366" + } } } }, diff --git a/packages/datatrak-web/src/api/mutations/useResubmitSurveyResponse.ts b/packages/datatrak-web/src/api/mutations/useResubmitSurveyResponse.ts index 2a6e22d161..c03060a4e5 100644 --- a/packages/datatrak-web/src/api/mutations/useResubmitSurveyResponse.ts +++ b/packages/datatrak-web/src/api/mutations/useResubmitSurveyResponse.ts @@ -5,113 +5,37 @@ import { useMutation } from 'react-query'; import { generatePath, useNavigate, useParams } from 'react-router'; -import { QuestionType } from '@tupaia/types'; -import { getBrowserTimeZone, getUniqueSurveyQuestionFileName } from '@tupaia/utils'; import { post } from '../api'; -import { getAllSurveyComponents, useSurveyForm } from '../../features'; -import { SurveyScreenComponent } from '../../types'; +import { useSurveyResponse } from '../queries'; +import { useSurveyForm } from '../../features'; import { ROUTES } from '../../constants'; -import { AnswersT, isFileUploadAnswer } from './useSubmitSurveyResponse'; - -const processAnswers = ( - answers: AnswersT, - questionsById: Record, -) => { - const files: File[] = []; - let entityId = null as string | null; - let dataTime = null as string | null; - const formattedAnswers = Object.entries(answers).reduce((acc, [questionId, answer]) => { - const { code, type } = questionsById[questionId]; - if (!code) return acc; - - if (type === QuestionType.PrimaryEntity && answer) { - entityId = answer as string; - return acc; - } - - if (type === QuestionType.File) { - if (isFileUploadAnswer(answer) && answer.value instanceof File) { - // Create a new file with a unique name, and add it to the files array, so we can add to the FormData, as this is what the central server expects - const uniqueFileName = getUniqueSurveyQuestionFileName(answer.name); - files.push( - new File([answer.value as Blob], uniqueFileName, { - type: answer.value.type, - }), - ); - return { - ...acc, - [code]: uniqueFileName, - }; - } - if (answer && typeof answer === 'object' && 'name' in answer) { - return { - ...acc, - [code]: answer.name, - }; - } - } - - if (type === QuestionType.DateOfData || type === QuestionType.SubmissionDate) { - const date = new Date(answer as string); - dataTime = date.toISOString(); - return acc; - } - return { - ...acc, - [code]: answer, - }; - }, {}); - - return { - answers: formattedAnswers, - files, - entityId, - dataTime, - }; -}; +import { AnswersT, useSurveyResponseData } from './useSubmitSurveyResponse'; export const useResubmitSurveyResponse = () => { const navigate = useNavigate(); const params = useParams(); const { surveyResponseId } = params; - const { surveyScreens, resetForm } = useSurveyForm(); - const allScreenComponents = getAllSurveyComponents(surveyScreens); - const questionsById = allScreenComponents.reduce((acc, component) => { - return { - ...acc, - [component.questionId]: component, - }; - }, {}); + + const { resetForm } = useSurveyForm(); + + const surveyResponseData = useSurveyResponseData(); + const { data: surveyResponse } = useSurveyResponse(surveyResponseId); + return useMutation( - async (surveyAnswers: AnswersT) => { - if (!surveyAnswers) { + async (answers: AnswersT) => { + if (!answers) { return; } - const { answers, files, entityId, dataTime } = processAnswers(surveyAnswers, questionsById); - const timezone = getBrowserTimeZone(); - const formData = new FormData(); - const formDataToSubmit = { answers, timezone } as { - answers: Record; - entity_id?: string; - data_time?: string; - timezone?: string; - }; - if (entityId) { - formDataToSubmit.entity_id = entityId; - } - if (dataTime) { - formDataToSubmit.data_time = dataTime; - } - - formData.append('payload', JSON.stringify(formDataToSubmit)); - files.forEach(file => { - formData.append(file.name, file); - }); - return post(`surveyResponse/${surveyResponseId}/resubmit`, { - data: formData, - headers: { - 'Content-Type': 'multipart/form-data', + return post(`resubmitSurveyResponse/${surveyResponseId}`, { + data: { + ...surveyResponseData, + answers, + // keep the same dataTime and userId as the original survey response + dataTime: surveyResponse?.dataTime ? surveyResponse?.dataTime : new Date(), + userId: surveyResponse?.userId, + entityId: surveyResponse?.entityId, + timezone: surveyResponse?.timezone, }, }); }, diff --git a/packages/datatrak-web/src/api/mutations/useSubmitSurveyResponse.ts b/packages/datatrak-web/src/api/mutations/useSubmitSurveyResponse.ts index 8e3564b12b..72b4b05ee8 100644 --- a/packages/datatrak-web/src/api/mutations/useSubmitSurveyResponse.ts +++ b/packages/datatrak-web/src/api/mutations/useSubmitSurveyResponse.ts @@ -6,7 +6,6 @@ import { useMutation, useQueryClient } from 'react-query'; import { generatePath, useNavigate, useParams } from 'react-router'; import { getBrowserTimeZone } from '@tupaia/utils'; -import { QuestionType } from '@tupaia/types'; import { Coconut } from '../../components'; import { post, useCurrentUserContext, useEntityByCode } from '..'; import { ROUTES } from '../../constants'; @@ -14,14 +13,7 @@ import { getAllSurveyComponents, useSurveyForm } from '../../features'; import { useSurvey } from '../queries'; import { gaEvent, successToast } from '../../utils'; -type Base64 = string | null | ArrayBuffer; - -type FileAnswerT = { - name: string; - value?: Base64 | File; -}; - -type Answer = string | number | boolean | null | undefined | FileAnswerT; +type Answer = string | number | boolean | null | undefined; export type AnswersT = Record; @@ -43,46 +35,6 @@ export const useSurveyResponseData = () => { }; }; -export const isFileUploadAnswer = (answer: Answer): answer is FileAnswerT => { - if (!answer || typeof answer !== 'object') return false; - return 'value' in answer; -}; - -const createEncodedFile = (fileObject?: File): Promise => { - if (!fileObject) { - return Promise.resolve(null); - } - return new Promise((resolve, reject) => { - const reader = new FileReader(); - - reader.onload = () => { - resolve(reader.result); - }; - - reader.onerror = reject; - - reader.readAsDataURL(fileObject); - }); -}; - -const processAnswers = async (answers: AnswersT, questionsById) => { - const formattedAnswers = { ...answers }; - for (const [questionId, answer] of Object.entries(answers)) { - const question = questionsById[questionId]; - if (!question) continue; - if (question.type === QuestionType.File && isFileUploadAnswer(answer)) { - // convert to an object with an encoded file so that it can be handled in the backend and uploaded to s3 - const encodedFile = await createEncodedFile(answer.value as File); - - formattedAnswers[questionId] = { - name: answer.name, - value: encodedFile, - }; - } - } - return formattedAnswers; -}; - export const useSubmitSurveyResponse = () => { const queryClient = useQueryClient(); const navigate = useNavigate(); @@ -93,20 +45,14 @@ export const useSubmitSurveyResponse = () => { const surveyResponseData = useSurveyResponseData(); - const questionsById = surveyResponseData.questions.reduce((acc, question) => { - acc[question.questionId] = question; - return acc; - }, {}); - return useMutation( async (answers: AnswersT) => { if (!answers) { return; } - const formattedAnswers = await processAnswers(answers, questionsById); return post('submitSurveyResponse', { - data: { ...surveyResponseData, answers: formattedAnswers }, + data: { ...surveyResponseData, answers }, }); }, { diff --git a/packages/datatrak-web/src/api/queries/useSurveyResponse.ts b/packages/datatrak-web/src/api/queries/useSurveyResponse.ts index 79d1e3aed5..6f0fd29f4f 100644 --- a/packages/datatrak-web/src/api/queries/useSurveyResponse.ts +++ b/packages/datatrak-web/src/api/queries/useSurveyResponse.ts @@ -9,6 +9,7 @@ import { get } from '../api'; import { ROUTES } from '../../constants'; import { errorToast } from '../../utils'; import { getAllSurveyComponents, useSurveyForm } from '../../features'; +import { stripTimezoneFromDate } from '@tupaia/utils'; export const useSurveyResponse = (surveyResponseId?: string) => { const { setFormData, surveyScreens } = useSurveyForm(); @@ -49,7 +50,20 @@ export const useSurveyResponse = (surveyResponseId?: string) => { ); if (!question) return acc; if (question.type === QuestionType.File && value) { - return { ...acc, [key]: { name: value, value: null } }; + // If the value is a file, split the value to get the file name + const withoutPrefix = value.split('files/'); + const fileNameParts = withoutPrefix[withoutPrefix.length - 1].split('_'); + // remove first element of the array as it is the file id + const fileName = fileNameParts.slice(1).join('_'); + return { ...acc, [key]: { name: fileName, value } }; + } + + if ( + (question.type === QuestionType.Date || question.type === QuestionType.DateTime) && + value + ) { + // strip timezone from date so that it gets displayed the same no matter the user's timezone + return { ...acc, [key]: stripTimezoneFromDate(value) }; } return { ...acc, [key]: isStringifiedObject ? JSON.parse(value) : value }; diff --git a/packages/datatrak-web/src/features/Questions/FileQuestion.tsx b/packages/datatrak-web/src/features/Questions/FileQuestion.tsx index 12d8ff263f..eab5604cc5 100644 --- a/packages/datatrak-web/src/features/Questions/FileQuestion.tsx +++ b/packages/datatrak-web/src/features/Questions/FileQuestion.tsx @@ -26,6 +26,22 @@ const Wrapper = styled.div` } `; +type Base64 = string | null | ArrayBuffer; + +const createEncodedFile = (fileObject: File): Promise => { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + + reader.onload = () => { + resolve(reader.result); + }; + + reader.onerror = reject; + + reader.readAsDataURL(fileObject); + }); +}; + export const FileQuestion = ({ label, required, @@ -40,12 +56,22 @@ export const FileQuestion = ({ return; } const file = files[0]; + const encodedFile = await createEncodedFile(file); + // convert to an object with an encoded file so that it can be handled in the backend and uploaded to s3 onChange({ name: file.name, - value: file, + value: encodedFile, }); }; + const getInitialFiles = () => { + if (selectedFile?.value) { + return [new File([selectedFile.value as Blob], selectedFile.name)]; + } + return undefined; + }; + + const initialFiles = getInitialFiles(); return ( ); diff --git a/packages/datatrak-web/src/features/Reports/Inputs/EntitySelectorInput.tsx b/packages/datatrak-web/src/features/Reports/Inputs/EntitySelectorInput.tsx index f6c57e8621..9d5db0a339 100644 --- a/packages/datatrak-web/src/features/Reports/Inputs/EntitySelectorInput.tsx +++ b/packages/datatrak-web/src/features/Reports/Inputs/EntitySelectorInput.tsx @@ -62,10 +62,11 @@ export const EntitySelectorInput = ({ selectedEntityLevel }: EntitySelectorInput id={selectedEntityLevel} getOptionSelected={(option, selected) => option.value === selected.value} options={ - entities?.map(({ name: entityName, id, code: secondaryLabel, type: entityType }) => ({ + entities?.map(({ name: entityName, id, code, type: entityType }) => ({ label: entityName, value: id, - secondaryLabel, + secondaryLabel: code, + code, type: entityType, })) ?? [] } diff --git a/packages/datatrak-web/src/features/Survey/Components/SurveyQuestion.tsx b/packages/datatrak-web/src/features/Survey/Components/SurveyQuestion.tsx index 6b239884c8..2ad7edefe0 100644 --- a/packages/datatrak-web/src/features/Survey/Components/SurveyQuestion.tsx +++ b/packages/datatrak-web/src/features/Survey/Components/SurveyQuestion.tsx @@ -73,7 +73,7 @@ export const SurveyQuestion = ({ ...props }: SurveyQuestionFieldProps) => { const { control, errors } = useFormContext(); - const { updateFormData, formData } = useSurveyForm(); + const { updateFormData, formData, isResubmit } = useSurveyForm(); const FieldComponent = QUESTION_TYPES[type]; if (!FieldComponent) { @@ -90,7 +90,7 @@ export const SurveyQuestion = ({ const getDefaultValue = () => { if (formData[name] !== undefined) return formData[name]; // This is so that the default value gets carried through to the component, and dates that have a visible value of 'today' have that value recognised when validating - if (type?.includes('Date')) return new Date(); + if (type?.includes('Date')) return isResubmit ? null : new Date(); return undefined; }; diff --git a/packages/datatrak-web/src/features/Survey/Screens/SurveyResubmitSuccessScreen.tsx b/packages/datatrak-web/src/features/Survey/Screens/SurveyResubmitSuccessScreen.tsx index 6d0df85b9f..c4a50e498b 100644 --- a/packages/datatrak-web/src/features/Survey/Screens/SurveyResubmitSuccessScreen.tsx +++ b/packages/datatrak-web/src/features/Survey/Screens/SurveyResubmitSuccessScreen.tsx @@ -16,13 +16,13 @@ const ButtonGroup = styled.div` const DEV_ADMIN_PANEL_URL = 'https://dev-admin.tupaia.org'; export const SurveyResubmitSuccessScreen = () => { - const getAdminPanelUrl = () => { + const getBaseAdminPanelUrl = () => { const { origin } = window.location; if (origin.includes('localhost')) return DEV_ADMIN_PANEL_URL; return origin.replace('datatrak', 'admin'); }; - const adminPanelUrl = getAdminPanelUrl(); + const adminPanelUrl = `${getBaseAdminPanelUrl()}/surveys/survey-responses`; return ( , updates: Record, screenComponents: SurveyScreenComponent[], ) => { const updatedFormData = { ...oldFormData, ...updates }; screenComponents?.forEach(component => { - const { questionId, visibilityCriteria } = component; + const { questionId, visibilityCriteria, type } = component; // if the question is not visible and is not set to always be hidden and has a value, reset the value if ( @@ -164,6 +165,34 @@ const resetInvisibleQuestions = ( ) { updatedFormData[questionId] = undefined; } + if ( + // if the question is an entity question and the value has changed, reset the value of all entity questions that depend on this question + (type === QuestionType.Entity || type === QuestionType.PrimaryEntity) && + updatedFormData.hasOwnProperty(questionId) && + updatedFormData[questionId] !== oldFormData[questionId] + ) { + // get all entity questions that depend on this question + const entityQuestionsFilteredByValue = screenComponents.filter(question => { + if (!question.config?.entity) return false; + const { entity } = question.config; + if (entity.filter) { + const { filter } = entity; + return Object.values(filter).some(filterValue => { + if (typeof filterValue === 'object' && 'questionId' in filterValue) { + return filterValue.questionId === questionId; + } + return false; + }); + } + return false; + }); + + // reset the value of all entity questions that depend on this question + entityQuestionsFilteredByValue.forEach(entityQuestion => { + const { questionId: entityQuestionId } = entityQuestion; + updatedFormData[entityQuestionId] = undefined; + }); + } }); return updatedFormData; @@ -239,14 +268,36 @@ export const generateCodeForCodeGeneratorQuestions = ( return formDataCopy; }; +/** + * @description Remove timezone from date answers so that the date is displayed correctly in the UI no matter the timezone. On submission these will be added back. + */ +const removeTimezoneFromDateAnswers = (updates, screenComponents: SurveyScreenComponent[]) => { + const updatedAnswers = { ...updates }; + screenComponents?.forEach(question => { + const { questionId, type } = question; + if (type.includes('Date')) { + if (updates[questionId]) { + updatedAnswers[questionId] = stripTimezoneFromDate(updates[questionId]); + } + } + }); + return updatedAnswers; +}; + export const getUpdatedFormData = ( updates: Record, formData: Record, screenComponents: SurveyScreenComponent[], ) => { - // reset the values of invisible questions first, in case the value of the invisible question is used in the formula of another question - const resetInvisibleQuestionData = resetInvisibleQuestions(formData, updates, screenComponents); - return updateDependentQuestions(resetInvisibleQuestionData, screenComponents); + const updatedValues = removeTimezoneFromDateAnswers(updates, screenComponents); + + // reset the values of invisible questions first, in case the value of the invisible question is used in the formula of another question. Also reset the value of filtered entity questions + const resetQuestionData = resetInvisibleAndFilteredQuestions( + formData, + updatedValues, + screenComponents, + ); + return updateDependentQuestions(resetQuestionData, screenComponents); }; export const getArithmeticDisplayAnswer = (config, answer, formData) => { diff --git a/packages/datatrak-web/src/views/SurveySelectPage/SurveySelectPage.tsx b/packages/datatrak-web/src/views/SurveySelectPage/SurveySelectPage.tsx index 3faf88c5b6..35255d6a26 100644 --- a/packages/datatrak-web/src/views/SurveySelectPage/SurveySelectPage.tsx +++ b/packages/datatrak-web/src/views/SurveySelectPage/SurveySelectPage.tsx @@ -4,6 +4,7 @@ */ import React, { useEffect, useState } from 'react'; import { useNavigate } from 'react-router'; +import { useSearchParams } from 'react-router-dom'; import styled from 'styled-components'; import { DialogActions, Paper, Typography } from '@material-ui/core'; import { SpinningLoader } from '@tupaia/ui-components'; @@ -102,6 +103,8 @@ const sortAlphanumerically = (a: ListItemType, b: ListItemType) => { export const SurveySelectPage = () => { const navigate = useNavigate(); const [selectedSurvey, setSelectedSurvey] = useState(null); + const [urlSearchParams] = useSearchParams(); + const urlProjectId = urlSearchParams.get('projectId'); const { countries, selectedCountry, @@ -112,7 +115,7 @@ export const SurveySelectPage = () => { const navigateToSurvey = () => { navigate(`/survey/${selectedCountry?.code}/${selectedSurvey?.value}`); }; - const { mutate: updateUser, isLoading: isUpdatingUser } = useEditUser(navigateToSurvey); + const { mutateAsync: updateUser, isLoading: isUpdatingUser } = useEditUser(); const user = useCurrentUserContext(); const { data: surveys, isLoading } = useProjectSurveys(user.projectId, selectedCountry?.name); @@ -162,7 +165,12 @@ export const SurveySelectPage = () => { const handleSelectSurvey = () => { if (countryHasUpdated) { // update user with new country. If the user goes 'back' and doesn't select a survey, and does not yet have a country selected, that's okay because it will be set whenever they next select a survey - updateUser({ countryId: selectedCountry?.id }); + updateUser( + { countryId: selectedCountry?.id }, + { + onSuccess: navigateToSurvey, + }, + ); } else navigateToSurvey(); }; @@ -173,7 +181,20 @@ export const SurveySelectPage = () => { } }, [JSON.stringify(surveys)]); - const showLoader = isLoading || isLoadingCountries || isUpdatingUser; + useEffect(() => { + const updateUserProject = async () => { + if (urlProjectId && user.projectId !== urlProjectId) { + updateUser({ projectId: urlProjectId }); + } + }; + updateUserProject(); + }, [urlProjectId]); + + const showLoader = + isLoading || + isLoadingCountries || + isUpdatingUser || + (urlProjectId && urlProjectId !== user?.projectId); // in this case the user will be updating and all surveys etc will be reloaded, so showing a loader when this is the case means a more seamless experience return ( @@ -181,11 +202,13 @@ export const SurveySelectPage = () => { Select survey Select a survey from the list below - + {!showLoader && ( + + )} {showLoader ? ( diff --git a/packages/meditrak-app/android/app/build.gradle b/packages/meditrak-app/android/app/build.gradle index 8d8718721e..f8715bf93d 100644 --- a/packages/meditrak-app/android/app/build.gradle +++ b/packages/meditrak-app/android/app/build.gradle @@ -84,8 +84,8 @@ android { applicationId "com.tupaiameditrak" minSdkVersion rootProject.ext.minSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion - versionCode 142 - versionName "1.14.142" + versionCode 143 + versionName "1.14.143" } signingConfigs { debug { diff --git a/packages/meditrak-app/android/build.gradle b/packages/meditrak-app/android/build.gradle index e37b24ff9c..e49894d61b 100644 --- a/packages/meditrak-app/android/build.gradle +++ b/packages/meditrak-app/android/build.gradle @@ -2,10 +2,10 @@ buildscript { ext { - buildToolsVersion = "33.0.0" + buildToolsVersion = "34.0.0" minSdkVersion = 21 - compileSdkVersion = 33 - targetSdkVersion = 33 + compileSdkVersion = 34 + targetSdkVersion = 34 // We use NDK 23 which has both M1 support and is the side-by-side NDK version from AGP. ndkVersion = "23.1.7779620" diff --git a/packages/meditrak-app/ios/TupaiaMediTrak.xcodeproj/project.pbxproj b/packages/meditrak-app/ios/TupaiaMediTrak.xcodeproj/project.pbxproj index 829f9607ed..c2678982d8 100644 --- a/packages/meditrak-app/ios/TupaiaMediTrak.xcodeproj/project.pbxproj +++ b/packages/meditrak-app/ios/TupaiaMediTrak.xcodeproj/project.pbxproj @@ -495,7 +495,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 142; + CURRENT_PROJECT_VERSION = 143; DEVELOPMENT_TEAM = 352QMCKRKJ; "DEVELOPMENT_TEAM[sdk=iphoneos*]" = 352QMCKRKJ; ENABLE_BITCODE = NO; @@ -504,7 +504,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.14.142; + MARKETING_VERSION = 1.14.143; OTHER_LDFLAGS = ( "$(inherited)", "-ObjC", @@ -528,7 +528,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 142; + CURRENT_PROJECT_VERSION = 143; DEVELOPMENT_TEAM = 352QMCKRKJ; "DEVELOPMENT_TEAM[sdk=iphoneos*]" = 352QMCKRKJ; INFOPLIST_FILE = TupaiaMediTrak/Info.plist; @@ -536,7 +536,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.14.142; + MARKETING_VERSION = 1.14.143; OTHER_LDFLAGS = ( "$(inherited)", "-ObjC", diff --git a/packages/meditrak-app/package.json b/packages/meditrak-app/package.json index fa82486b76..2c8b40bc75 100644 --- a/packages/meditrak-app/package.json +++ b/packages/meditrak-app/package.json @@ -1,6 +1,6 @@ { "name": "@tupaia/meditrak-app", - "version": "1.14.142", + "version": "1.14.143", "private": true, "scripts": { "android": "react-native run-android", diff --git a/packages/server-utils/src/s3/S3Client.ts b/packages/server-utils/src/s3/S3Client.ts index 550421dc88..c91f5b1cd3 100644 --- a/packages/server-utils/src/s3/S3Client.ts +++ b/packages/server-utils/src/s3/S3Client.ts @@ -101,7 +101,7 @@ export class S3Client { const buffer = Buffer.from(encodedImageString, 'base64'); // use the file type from the image if it's available, otherwise default to png - const fileType = + let fileType = base64EncodedImage.includes('data:image') && base64EncodedImage.includes(';base64') ? base64EncodedImage.substring('data:image/'.length, base64EncodedImage.indexOf(';base64')) : 'png'; @@ -109,6 +109,8 @@ export class S3Client { // If is not an image file type, e.g. a pdf, throw an error if (!imageTypes.includes(fileType)) throw new Error(`File type ${fileType} is not supported`); + if (fileType === 'jpeg') fileType = 'jpg'; + const fileExtension = fileType.replace('+xml', ''); const filePath = getS3ImageFilePath(); diff --git a/packages/tupaia-web/package.json b/packages/tupaia-web/package.json index 6189e31a59..078999e979 100644 --- a/packages/tupaia-web/package.json +++ b/packages/tupaia-web/package.json @@ -43,6 +43,8 @@ "react-query": "^3.39.3", "react-router": "6.3.0", "react-router-dom": "6.3.0", + "react-slick": "^0.30.2", + "slick-carousel": "^1.8.1", "styled-components": "^5.1.0", "vite-plugin-ejs": "^1.6.4", "vite-plugin-env-compatible": "^1.1.1" diff --git a/packages/tupaia-web/src/features/Dashboard/ExportDashboard/SelectVisualisations.tsx b/packages/tupaia-web/src/features/Dashboard/ExportDashboard/SelectVisualisations.tsx index 15e958ab53..b01b939c3d 100644 --- a/packages/tupaia-web/src/features/Dashboard/ExportDashboard/SelectVisualisations.tsx +++ b/packages/tupaia-web/src/features/Dashboard/ExportDashboard/SelectVisualisations.tsx @@ -85,7 +85,7 @@ export const SelectVisualisation = ({ name: config?.name, code, disabled: !isSupported, - tooltip: !isSupported ? 'PDF export coming soon' : undefined, + tooltip: !isSupported ? 'PDF export unavailable' : undefined, }; }) ?? []; diff --git a/packages/tupaia-web/src/features/EnlargedDashboardItem/EnlargedDashboardItem.tsx b/packages/tupaia-web/src/features/EnlargedDashboardItem/EnlargedDashboardItem.tsx index 6dc26211ce..709f6c8436 100644 --- a/packages/tupaia-web/src/features/EnlargedDashboardItem/EnlargedDashboardItem.tsx +++ b/packages/tupaia-web/src/features/EnlargedDashboardItem/EnlargedDashboardItem.tsx @@ -26,21 +26,21 @@ const StyledModal = styled(Modal)` } `; -// MatrixWrapper is an expanding wrapper that allows the matrix to grow to the full width of the screen -const MatrixWrapper = css` +// ExpandingWrapper is an expanding wrapper that allows the viz to grow to the full width of the screen +const ExpandingWrapper = css` max-width: 90vw; padding: 0 0.5rem; width: auto; `; -// BigChartWrapper is a wrapper that sets the chart to be full width when there is a lot of data. This needs to be separate from MatrixWrapper because recharts needs a fixed width to expand because it calculates the svg width based on the width of the parent div +// BigChartWrapper is a wrapper that sets the chart to be full width when there is a lot of data. This needs to be separate from ExpandingWrapper because recharts needs a fixed width to expand because it calculates the svg width based on the width of the parent div const BigChartWrapper = css` min-width: 90vw; width: 90%; `; const Wrapper = styled.div<{ - $isMatrix?: boolean; + $isExpanding?: boolean; $hasBigData?: boolean; }>` min-height: 25rem; @@ -50,7 +50,7 @@ const Wrapper = styled.div<{ padding: 0 0.5rem; width: 45rem; - ${({ $isMatrix }) => $isMatrix && MatrixWrapper} + ${({ $isExpanding }) => $isExpanding && ExpandingWrapper} ${({ $hasBigData }) => $hasBigData && BigChartWrapper} `; @@ -60,19 +60,31 @@ const ContentWrapper = ({ children }: { children: React.ReactNode }) => { const { currentDashboardItem, reportData } = useEnlargedDashboardItem(); - const isMatrix = currentDashboardItem?.config?.type === 'matrix'; + const getIsExpanding = () => { + if (!currentDashboardItem) return false; + if (currentDashboardItem?.config?.type === 'matrix') return true; + if (currentDashboardItem?.config?.type === 'view') { + const { viewType } = currentDashboardItem?.config; + return viewType === 'multiPhotograph'; + } + return false; + }; + + const isExpandingSize = getIsExpanding(); const getHasBigData = () => { - if (!reportData || isExportMode || currentDashboardItem?.config?.type !== 'chart') return false; + if (!reportData || isExportMode) return false; // only charts with more than 20 data points are considered big. Matrix will expand to fit the screen if there is a lot of data, and 'view' type dashboards are always fixed because the data is semi-static - const { data } = reportData as BaseReport; - return data ? data?.length > 20 : false; + const { data = [] } = reportData as BaseReport; + + if (currentDashboardItem?.config?.type !== 'chart') return false; + return data?.length > 20; }; const hasBigData = getHasBigData(); return ( - + {children} ); diff --git a/packages/tupaia-web/src/features/Visuals/View/MultiPhotograph/Carousel.tsx b/packages/tupaia-web/src/features/Visuals/View/MultiPhotograph/Carousel.tsx deleted file mode 100644 index bb9190c616..0000000000 --- a/packages/tupaia-web/src/features/Visuals/View/MultiPhotograph/Carousel.tsx +++ /dev/null @@ -1,105 +0,0 @@ -/** - * Tupaia - * Copyright (c) 2017 - 2023 Beyond Essential Systems Pty Ltd - */ -import React, { useState } from 'react'; -import styled from 'styled-components'; -import { ViewReport } from '@tupaia/types'; -import { Modal } from '../../../../components'; -import { IconButton, Slide } from '@material-ui/core'; -import { KeyboardArrowLeft, KeyboardArrowRight } from '@material-ui/icons'; - -const Wrapper = styled.div` - overflow-y: auto; - max-width: 38rem; - width: 100%; - margin: 0 1rem; - flex: 1; - display: flex; -`; - -const ImageWrapper = styled.div` - overflow: hidden; - width: 100%; - min-height: 30rem; - height: 100%; - display: flex; - justify-content: center; - align-items: center; -`; -const Image = styled.img<{ - $isActive: boolean; -}>` - max-width: 100%; - height: auto; - max-height: 100%; - display: ${({ $isActive }) => ($isActive ? 'block' : 'none')}; - margin: 0 auto; -`; - -const ArrowButton = styled(IconButton)` - position: absolute; - top: 45%; - z-index: 1; - &:first-child { - left: 0.5rem; - } - &:last-child { - right: 2.5rem; - } - svg { - width: 3rem; - height: 3rem; - } -`; - -interface CarouselProps { - report: ViewReport; - onClose: () => void; -} - -export const Carousel = ({ report: { data = [] }, onClose }: CarouselProps) => { - const [activeImageIndex, setActiveImageIndex] = useState(0); - const [slideDirection, setSlideDirection] = useState<'left' | 'right'>('right'); - - const handleNext = () => { - if (activeImageIndex === data.length - 1) return; - setSlideDirection('right'); - setActiveImageIndex(activeImageIndex + 1); - }; - const handlePrevious = () => { - if (activeImageIndex === 0) return; - setSlideDirection('left'); - setActiveImageIndex(activeImageIndex - 1); - }; - - if (!data[activeImageIndex]) return null; - return ( - - - - {data.map((image, index) => ( - - - - ))} - - {data.length > 1 && ( - <> - - - - - - - - )} - - - ); -}; diff --git a/packages/tupaia-web/src/features/Visuals/View/MultiPhotograph/MultiPhotographEnlarged.tsx b/packages/tupaia-web/src/features/Visuals/View/MultiPhotograph/MultiPhotographEnlarged.tsx index b8919a2a12..8c0bd55ec1 100644 --- a/packages/tupaia-web/src/features/Visuals/View/MultiPhotograph/MultiPhotographEnlarged.tsx +++ b/packages/tupaia-web/src/features/Visuals/View/MultiPhotograph/MultiPhotographEnlarged.tsx @@ -1,54 +1,257 @@ /** * Tupaia - * Copyright (c) 2017 - 2023 Beyond Essential Systems Pty Ltd + * Copyright (c) 2017 - 2024 Beyond Essential Systems Pty Ltd */ -import React, { useState } from 'react'; +import React, { useEffect, useRef, useState } from 'react'; import styled from 'styled-components'; import { ViewConfig, ViewReport } from '@tupaia/types'; -import { MultiPhotographPreview } from './MultiPhotographPreview'; -import { ZoomIn as MuiZoomIn } from '@material-ui/icons'; -import { Carousel } from './Carousel'; +import 'slick-carousel/slick/slick.css'; +import 'slick-carousel/slick/slick-theme.css'; +import Slider from 'react-slick'; +import { KeyboardArrowLeft, KeyboardArrowRight } from '@material-ui/icons'; +import { IconButton, Typography } from '@material-ui/core'; const Wrapper = styled.div` position: relative; + width: 75rem; + max-width: 90%; + margin: 0 auto; `; -const ExpandButton = styled.button` - position: absolute; - cursor: pointer; - top: 0; - left: 0; - border: none; - width: 100%; +const MainSliderContainer = styled.div` + max-width: 37rem; + margin: 0 auto; +`; + +const ThumbSliderContainer = styled.div` + max-width: 65rem; + margin: 0 auto; +`; + +const Image = styled.div<{ + url?: string; +}>` + background-image: url(${({ url }) => url}); + background-size: contain; + background-repeat: no-repeat; + background-position: center; height: 100%; - opacity: 0; - background-color: rgba(32, 33, 36, 0.6); - color: ${({ theme }) => theme.palette.common.white}; + width: 100%; + display: flex; + justify-content: center; + align-items: center; +`; + +const Slide = styled.div` + display: flex; + flex-direction: column; +`; + +const MainSlide = styled(Slide)` + width: 100%; + height: 35rem; + padding-block-start: 1rem; + padding-block-end: 3.5rem; + padding-inline: 1.5rem; +`; + +const Caption = styled(Typography)` + text-align: left; + font-size: 0.875rem; + font-weight: ${({ theme }) => theme.typography.fontWeightMedium}; + margin-block-start: 0.3rem; +`; + +const ArrowButton = styled(IconButton)` + padding: 1rem; + display: flex; + color: ${({ theme }) => theme.palette.text.primary}; + // ignore the default arrow icon + &:before { + content: ''; + } &:hover, - &:focus-visible { - opacity: 1; + &:focus-within { + color: ${({ theme }) => theme.palette.primary.main}; + } + &.slick-disabled { + pointer-events: none; + opacity: 0.5; } `; -const ZoomIn = styled(MuiZoomIn)` - width: 4rem; - height: 4rem; +const Thumbnail = styled(Slide)<{ + $thumbCount: number; +}>` + padding: 0.4rem; + height: 100%; + width: 100%; + max-height: 100%; + max-width: 100%; + ${Image} { + border-radius: 3px; + background-size: cover; + .slick-current & { + border: 2px solid ${({ theme }) => theme.palette.text.primary}; + } + &:hover { + border: 1px solid ${({ theme }) => theme.palette.text.primary}; + } + } + .slick-slide:has(&) { + width: ${({ $thumbCount }) => + `calc(100% / ${$thumbCount}) !important`}; // This is to override the default width set by slick-carousel, and needs to be !important to take effect because slick applies this inline + max-width: 7rem; + height: auto; + aspect-ratio: 1 / 1; + display: flex; + > div { + height: 100%; + width: 100%; + } + } + .slick-track:has(&) { + display: flex; + justify-content: center; + `; +interface ArrowIconWrapperProps extends Record { + left?: boolean; + style?: Record; +} + +const ArrowIconWrapper = ({ style, left, ...props }: ArrowIconWrapperProps) => { + const Icon = left ? KeyboardArrowLeft : KeyboardArrowRight; + return ( + + + + ); +}; + interface MultiPhotographEnlargedProps { report: ViewReport; config: ViewConfig; } export const MultiPhotographEnlarged = ({ report, config }: MultiPhotographEnlargedProps) => { - const [carouselOpen, setCarouselOpen] = useState(false); + const { data = [] } = report ?? {}; + + /** The following is a workaround for an issue where the slider doesn't link the two sliders together until another re-render, because the ref is not set yet + * See [example](https://react-slick.neostack.com/docs/example/as-nav-for) + **/ + + const sliderRef1 = useRef(null); + const sliderRef2 = useRef(null); + const [mainSlider, setMainSlider] = useState(null); + const [thumbnailSlider, setThumbnailSlider] = useState(null); + + useEffect(() => { + if (!sliderRef1.current || mainSlider) return; + setMainSlider(sliderRef1.current); + }, [sliderRef1.current]); + + useEffect(() => { + if (!sliderRef2.current || thumbnailSlider) return; + setThumbnailSlider(sliderRef2.current); + }, [sliderRef2.current]); + + // END of workaround + + const getThumbsToShow = max => { + return Math.min(max, data.length); + }; + + const maxThumbnailsToDisplay = getThumbsToShow(12); + + const settings = { + speed: 500, + prevArrow: , + nextArrow: , + slidesToScroll: 1, + swipeToSlide: true, + }; + + const getResponsiveSettingForBreakpoint = (breakpoint, maxSlides, isMainSlider) => { + const thumbCount = getThumbsToShow(maxSlides); + // only show arrows and allow infinite scrolling if there are more thumbnails than can be displayed + const isInfinite = data.length > thumbCount; + return { + breakpoint, + settings: { + slidesToShow: isMainSlider ? 1 : thumbCount, + infinite: isInfinite, + arrows: isMainSlider ? true : isInfinite, + }, + }; + }; + + const responsiveArr = [ + { breakpoint: 400, maxSlides: 3 }, + { breakpoint: 600, maxSlides: 6 }, + { + breakpoint: 800, + maxSlides: 8, + }, + ]; + + // settings for the thumbnail slider to reduce the number of thumbnails shown on smaller screens + const thumbnailResponsiveSettings = responsiveArr.map(({ breakpoint, maxSlides }) => + getResponsiveSettingForBreakpoint(breakpoint, maxSlides, false), + ); + + const mainSliderResponsiveSettings = responsiveArr.map(({ breakpoint, maxSlides }) => + getResponsiveSettingForBreakpoint(breakpoint, maxSlides, true), + ); + + const hasMoreThumbnails = data.length > maxThumbnailsToDisplay; + return ( - - setCarouselOpen(true)}> - - - {carouselOpen && setCarouselOpen(false)} />} + + + {data.map((photo, index) => ( + + + {photo.label && {photo.label}} + + ))} + + + + + + {data?.map((photo, index) => ( + + + + ))} + + ); }; diff --git a/packages/tupaia-web/src/features/Visuals/View/MultiPhotograph/MultiPhotographPreview.tsx b/packages/tupaia-web/src/features/Visuals/View/MultiPhotograph/MultiPhotographPreview.tsx index cf94408cae..0e9422737e 100644 --- a/packages/tupaia-web/src/features/Visuals/View/MultiPhotograph/MultiPhotographPreview.tsx +++ b/packages/tupaia-web/src/features/Visuals/View/MultiPhotograph/MultiPhotographPreview.tsx @@ -5,30 +5,43 @@ import React from 'react'; import styled from 'styled-components'; import { ViewConfig, ViewReport } from '@tupaia/types'; - -const MAX_THUMBNAILS = 3; +import { Typography } from '@material-ui/core'; const Wrapper = styled.div` display: flex; + flex-wrap: wrap; + justify-content: center; + align-items: center; + gap: 0.625rem; height: 16rem; `; const Thumbnail = styled.div<{ thumbCount: number; - src: string; + src?: string; }>` - max-height: 100%; + height: ${({ thumbCount }) => { + if (thumbCount === 1) return '100%'; + if (thumbCount === 2) return '12rem'; + return '50%'; + }}; + max-height: ${({ thumbCount }) => { + if (thumbCount <= 2) return '100%'; + return '7.5rem'; + }}; background-image: url(${({ src }) => src}); background-size: cover; + background-position: center; background-repeat: no-repeat; width: ${({ thumbCount }) => { if (thumbCount === 1) return '100%'; - if (thumbCount === 2) return '50%'; - return '25%'; + return 'min(48%, 11rem)'; }}; - &:nth-child(2) { - width: 50%; - } + border-radius: 3px; + background-color: ${({ theme }) => theme.palette.background.paper}; + display: flex; + justify-content: center; + align-items: center; `; const Image = styled.img` @@ -48,6 +61,11 @@ const Image = styled.img` } `; +const MoreImagesText = styled(Typography)` + font-size: 1.125rem; + font-weight: ${({ theme }) => theme.typography.fontWeightMedium}; +`; + interface MultiPhotographPreviewProps { report: ViewReport; config: ViewConfig; @@ -74,17 +92,26 @@ export const MultiPhotographPreview = ({ ); } - const thumbnails = data.slice(0, MAX_THUMBNAILS).map(({ value }) => value); + const maxThumbnailsToDisplay = 3; + const thumbnails = data.slice(0, maxThumbnailsToDisplay).map(({ value }) => value); + + const remainingThumbnails = data.length - maxThumbnailsToDisplay; + return ( {thumbnails.map((thumbnail, i) => ( ))} + {remainingThumbnails > 0 && ( + + + {remainingThumbnails} + + )} ); }; diff --git a/packages/types/src/schemas/schemas.ts b/packages/types/src/schemas/schemas.ts index d8d562a033..224b11c6f4 100644 --- a/packages/types/src/schemas/schemas.ts +++ b/packages/types/src/schemas/schemas.ts @@ -30896,6 +30896,9 @@ export const ViewDataItemSchema = { "singleValue" ], "type": "string" + }, + "label": { + "type": "string" } } } @@ -30976,6 +30979,9 @@ export const ViewReportSchema = { "singleValue" ], "type": "string" + }, + "label": { + "type": "string" } } } @@ -31231,6 +31237,9 @@ export const DashboardItemReportSchema = { "singleValue" ], "type": "string" + }, + "label": { + "type": "string" } } } diff --git a/packages/types/src/types/models-extra/report.ts b/packages/types/src/types/models-extra/report.ts index 7c45ba924c..f300e0c502 100644 --- a/packages/types/src/types/models-extra/report.ts +++ b/packages/types/src/types/models-extra/report.ts @@ -65,6 +65,7 @@ export type ViewDataItem = value?: any; total?: number; viewType?: ViewConfig['viewType']; + label?: string; }) | DownloadFilesVisualDataItem; diff --git a/packages/types/src/types/requests/datatrak-web-server/ResubmitSurveyResponseRequest.ts b/packages/types/src/types/requests/datatrak-web-server/ResubmitSurveyResponseRequest.ts new file mode 100644 index 0000000000..fc92dbf293 --- /dev/null +++ b/packages/types/src/types/requests/datatrak-web-server/ResubmitSurveyResponseRequest.ts @@ -0,0 +1,16 @@ +/* + * Tupaia + * Copyright (c) 2017 - 2023 Beyond Essential Systems Pty Ltd + */ +import { Entity } from '../../models'; +import { + ResBody as SubmitSurveyResponseRequestResBody, + ReqBody as SubmitSurveyResponseRequestBody, +} from './SubmitSurveyResponseRequest'; + +export type Params = { originalSurveyResponseId: string }; +export type ResBody = SubmitSurveyResponseRequestResBody; +export type ReqBody = SubmitSurveyResponseRequestBody & { + entityId?: Entity['id']; +}; +export type ReqQuery = Record; diff --git a/packages/types/src/types/requests/datatrak-web-server/SingleSurveyResponseRequest.ts b/packages/types/src/types/requests/datatrak-web-server/SingleSurveyResponseRequest.ts index 90201a7e82..270fafe450 100644 --- a/packages/types/src/types/requests/datatrak-web-server/SingleSurveyResponseRequest.ts +++ b/packages/types/src/types/requests/datatrak-web-server/SingleSurveyResponseRequest.ts @@ -10,12 +10,13 @@ export type Params = { id: string; }; -export interface ResBody extends KeysToCamelCase { +export interface ResBody extends KeysToCamelCase> { answers: Record; countryName: Country['name']; entityName: Entity['name']; surveyName: Survey['name']; surveyCode: Survey['code']; + dataTime: Date; } export type ReqBody = Record; export type ReqQuery = Record; diff --git a/packages/types/src/types/requests/datatrak-web-server/SubmitSurveyResponseRequest.ts b/packages/types/src/types/requests/datatrak-web-server/SubmitSurveyResponseRequest.ts index 91e4a12af9..e2864ea7a8 100644 --- a/packages/types/src/types/requests/datatrak-web-server/SubmitSurveyResponseRequest.ts +++ b/packages/types/src/types/requests/datatrak-web-server/SubmitSurveyResponseRequest.ts @@ -22,6 +22,7 @@ interface SurveyResponse { answers: Answers; startTime: string; timezone: string; + dataTime?: string; } export type Params = Record; diff --git a/packages/types/src/types/requests/datatrak-web-server/index.ts b/packages/types/src/types/requests/datatrak-web-server/index.ts index 7fb2c6887e..fcec54e1bc 100644 --- a/packages/types/src/types/requests/datatrak-web-server/index.ts +++ b/packages/types/src/types/requests/datatrak-web-server/index.ts @@ -9,6 +9,7 @@ export * as DatatrakWebSurveysRequest from './SurveysRequest'; export * as DatatrakWebProjectsRequest from './ProjectsRequest'; export * as DatatrakWebSurveyRequest from './SurveyRequest'; export * as DatatrakWebSubmitSurveyResponseRequest from './SubmitSurveyResponseRequest'; +export * as DatatrakWebResubmitSurveyResponseRequest from './ResubmitSurveyResponseRequest'; export * as DatatrakWebSurveyResponsesRequest from './SurveyResponsesRequest'; export * as DatatrakWebRecentSurveysRequest from './RecentSurveysRequest'; export * as DatatrakWebSingleSurveyResponseRequest from './SingleSurveyResponseRequest'; diff --git a/packages/types/src/types/requests/index.ts b/packages/types/src/types/requests/index.ts index 8a82756dbc..d3010441b9 100644 --- a/packages/types/src/types/requests/index.ts +++ b/packages/types/src/types/requests/index.ts @@ -11,6 +11,7 @@ export { DatatrakWebUserRequest, DatatrakWebSurveysRequest, DatatrakWebSubmitSurveyResponseRequest, + DatatrakWebResubmitSurveyResponseRequest, DatatrakWebSurveyRequest, DatatrakWebSurveyResponsesRequest, DatatrakWebRecentSurveysRequest, diff --git a/packages/ui-components/src/components/FilterableTable/FilterCell.tsx b/packages/ui-components/src/components/FilterableTable/FilterCell.tsx index cbe6ee77b5..d939bedd44 100644 --- a/packages/ui-components/src/components/FilterableTable/FilterCell.tsx +++ b/packages/ui-components/src/components/FilterableTable/FilterCell.tsx @@ -64,7 +64,7 @@ export const DefaultFilter = styled(TextField).attrs(props => ({ padding-inline-start: 0.3rem; } .MuiSvgIcon-root { - color: ${({ theme }) => theme.palette.text.secondary}; + color: ${({ theme }) => theme.palette.text.tertiary}; } `; diff --git a/packages/ui-components/src/components/Inputs/FileUploadField.tsx b/packages/ui-components/src/components/Inputs/FileUploadField.tsx index a1cf6d9d99..56d7ac2884 100644 --- a/packages/ui-components/src/components/Inputs/FileUploadField.tsx +++ b/packages/ui-components/src/components/Inputs/FileUploadField.tsx @@ -154,6 +154,7 @@ export interface FileUploadFieldProps { * has a file selected. */ disabled?: boolean; + initialFiles?: File[]; } export const FileUploadField = ({ @@ -169,6 +170,7 @@ export const FileUploadField = ({ tooltip, disabled = false, fileName, + initialFiles = [], }: FileUploadFieldProps) => { if (disabled) return ( @@ -191,7 +193,7 @@ export const FileUploadField = ({ * * This array uses set semantics. See {@link onDropAccepted}. */ - const [files, setFiles] = useState([]); + const [files, setFiles] = useState(initialFiles); const hasFileSelected = files.length > 0; /** @@ -220,6 +222,13 @@ export const FileUploadField = ({ /** Propagates file selection changes to parent */ useEffect(() => { + // if the files are the same as the initial files, don't call onChange + if ( + initialFiles.length && + files.length && + files.every((file, i) => initialFiles?.[i]?.name === file.name) + ) + return; onChange(files); }, [files]); diff --git a/packages/utils/package.json b/packages/utils/package.json index d2560e7bc4..c0c9047208 100644 --- a/packages/utils/package.json +++ b/packages/utils/package.json @@ -28,6 +28,8 @@ "bson-objectid": "^1.2.2", "case": "^1.5.5", "countrynames": "^0.1.1", + "date-fns": "^2.29.2", + "date-fns-tz": "^2.0.1", "fast-memoize": "^2.5.2", "jsonwebtoken": "^8.5.1", "lodash.get": "^4.4.2", diff --git a/packages/utils/src/__tests__/timezone.test.js b/packages/utils/src/__tests__/timezone.test.js new file mode 100644 index 0000000000..e6dc9528d2 --- /dev/null +++ b/packages/utils/src/__tests__/timezone.test.js @@ -0,0 +1,23 @@ +/** + * Tupaia + * Copyright (c) 2017 - 2024 Beyond Essential Systems Pty Ltd + */ + +import { getOffsetForTimezone } from '../timezone'; + +describe('getOffsetForTimezone', () => { + it('Should return the correct offset for the timezone', async () => { + expect(getOffsetForTimezone('Pacific/Auckland')).toBe('+12:00'); + expect(getOffsetForTimezone('Pacific/Chatham')).toBe('+12:45'); + expect(getOffsetForTimezone('Pacific/Fiji')).toBe('+12:00'); + expect(getOffsetForTimezone('Asia/Kolkata')).toBe('+05:30'); + expect(getOffsetForTimezone('Asia/Kathmandu')).toBe('+05:45'); + expect(getOffsetForTimezone('Asia/Tehran')).toBe('+03:30'); + expect(getOffsetForTimezone('Europe/London')).toBe('+01:00'); + expect(getOffsetForTimezone('America/New_York')).toBe('-04:00'); + expect(getOffsetForTimezone('America/Los_Angeles')).toBe('-07:00'); + expect(getOffsetForTimezone('Pacific/Honolulu')).toBe('-10:00'); + expect(getOffsetForTimezone('Australia/Lord_Howe', new Date('2021-03-08'))).toBe('+11:00'); + expect(getOffsetForTimezone('Australia/Lord_Howe', new Date('2021-07-08'))).toBe('+10:30'); + }); +}); diff --git a/packages/utils/src/index.js b/packages/utils/src/index.js index 3d75b896df..6f041c377a 100644 --- a/packages/utils/src/index.js +++ b/packages/utils/src/index.js @@ -42,3 +42,4 @@ export { createClassExtendingProxy } from './proxy'; export { fetchPatiently } from './fetchPatiently'; export { oneSecondSleep, sleep } from './sleep'; export { getUniqueSurveyQuestionFileName } from './getUniqueSurveyQuestionFileName'; +export { formatDateInTimezone, getOffsetForTimezone } from './timezone'; diff --git a/packages/utils/src/timezone.js b/packages/utils/src/timezone.js new file mode 100644 index 0000000000..79cc842ff0 --- /dev/null +++ b/packages/utils/src/timezone.js @@ -0,0 +1,31 @@ +/** + * Tupaia + * Copyright (c) 2017 - 2024 Beyond Essential Systems Pty Ltd + */ + +import { formatInTimeZone, getTimezoneOffset } from 'date-fns-tz'; + +export const formatDateInTimezone = (date, timezone, format = "yyyy-MM-dd'T'HH:mm:ssXXX") => { + return formatInTimeZone(date, timezone, format); +}; + +export const getOffsetForTimezone = (timezone, date) => { + // the offset is given in ms, so we need to convert it to hours + const offset = getTimezoneOffset(timezone, date) / 60 / 60 / 1000; + + // round to 2 decimal places + const offsetDec = Math.round(offset * 100) / 100; + + // split the offset into hours and minutes + const hours = Math.abs(Math.floor(offsetDec)); + const mins = (offsetDec % 1) * 60 || '00'; + // add the correct prefix + const prefix = offset > 0 ? '+' : '-'; + // add leading zero to hours if needed + const leadingZero = Math.abs(hours) < 10 ? '0' : ''; + + // create the offset string + const offsetStr = `${prefix}${leadingZero}${hours}:${mins}`; + + return offsetStr; +}; diff --git a/packages/web-config-server/src/apiV1/permissions/ReportPermissionsChecker.js b/packages/web-config-server/src/apiV1/permissions/ReportPermissionsChecker.js index 6b5b677cc0..0fab8ba0b1 100644 --- a/packages/web-config-server/src/apiV1/permissions/ReportPermissionsChecker.js +++ b/packages/web-config-server/src/apiV1/permissions/ReportPermissionsChecker.js @@ -4,6 +4,18 @@ */ import { PermissionsError } from '@tupaia/utils'; import { PermissionsChecker } from './PermissionsChecker'; +import { RECORDS } from '@tupaia/database'; + +const getMergedProperties = (records, property) => { + return [ + ...new Set( + records.reduce( + (combinedList, singleRecord) => [...combinedList, ...singleRecord[property]], + [], + ), + ), + ]; +}; export class ReportPermissionsChecker extends PermissionsChecker { async fetchAndCacheDashboardItem(itemCode) { @@ -21,12 +33,30 @@ export class ReportPermissionsChecker extends PermissionsChecker { if (!this.permissionObject) { const { reportCode } = this.params; const { itemCode, dashboardCode } = this.query; - const dashboardRelation = await this.models.dashboardRelation.findDashboardRelation( - dashboardCode, - itemCode, + + // We want to ensure that we allow access to all the dashboard items that a user has permission to + // so get all the the dashboard relations for the dashboard code and item and combine their + // permission groups, entity types and project codes + const dashboardRelations = await this.models.dashboardRelation.find( + { + 'dashboard.code': dashboardCode, + 'dashboard_item.code': itemCode, + }, + { + multiJoin: [ + { + joinWith: RECORDS.DASHBOARD, + joinCondition: ['dashboard.id', 'dashboard_relation.dashboard_id'], + }, + { + joinWith: RECORDS.DASHBOARD_ITEM, + joinCondition: ['dashboard_item.id', 'dashboard_relation.child_id'], + }, + ], + }, ); - if (!dashboardRelation) { + if (dashboardRelations.length === 0) { throw new PermissionsError( `Cannot find relation between dashboard '${dashboardCode}' and dashboard item '${itemCode}'`, ); @@ -39,11 +69,9 @@ export class ReportPermissionsChecker extends PermissionsChecker { ); } - const { - permission_groups: permissionGroups, - entity_types: entityTypes, - project_codes: projectCodes, - } = dashboardRelation; + const permissionGroups = getMergedProperties(dashboardRelations, 'permission_groups'); + const entityTypes = getMergedProperties(dashboardRelations, 'entity_types'); + const projectCodes = getMergedProperties(dashboardRelations, 'project_codes'); this.permissionObject = { permissionGroups, diff --git a/yarn.lock b/yarn.lock index ad60ef123d..fadef962f1 100644 --- a/yarn.lock +++ b/yarn.lock @@ -12041,6 +12041,8 @@ __metadata: "@types/lodash.keyby": ^4.6.0 "@types/lodash.sortby": ^4.6.0 camelcase-keys: ^6.2.2 + date-fns: ^2.29.2 + date-fns-tz: ^2.0.1 express: ^4.19.2 lodash.groupby: ^4.6.0 lodash.keyby: ^4.6.0 @@ -12594,6 +12596,8 @@ __metadata: react-query: ^3.39.3 react-router: 6.3.0 react-router-dom: 6.3.0 + react-slick: ^0.30.2 + slick-carousel: ^1.8.1 storybook: ^7.0.18 styled-components: ^5.1.0 vite: ^4.5.3 @@ -12729,6 +12733,8 @@ __metadata: bson-objectid: ^1.2.2 case: ^1.5.5 countrynames: ^0.1.1 + date-fns: ^2.29.2 + date-fns-tz: ^2.0.1 fast-memoize: ^2.5.2 jsonwebtoken: ^8.5.1 lodash.get: ^4.4.2 @@ -20180,6 +20186,15 @@ __metadata: languageName: node linkType: hard +"date-fns-tz@npm:^2.0.1": + version: 2.0.1 + resolution: "date-fns-tz@npm:2.0.1" + peerDependencies: + date-fns: 2.x + checksum: 2d2bb3d67f1ac876a7bd3bbe027aba61347d21c74f20a3f014c53d6057a7a1653ed3021c2d9271dc24411ab38f82763fec362c4f24e944465172cd80ee8c4b5f + languageName: node + linkType: hard + "date-fns@npm:^1.27.2, date-fns@npm:^1.3.0": version: 1.30.1 resolution: "date-fns@npm:1.30.1" @@ -21749,6 +21764,13 @@ __metadata: languageName: node linkType: hard +"enquire.js@npm:^2.1.6": + version: 2.1.6 + resolution: "enquire.js@npm:2.1.6" + checksum: bb094054ee2768edafc3b80fb2b65b79d63160d625085162e9c9016843b6eaa13d7430201bf593c2e31e1725b3208f47f57aa27e03b19729e7590d1ca2241451 + languageName: node + linkType: hard + "enquirer@npm:2.3.6": version: 2.3.6 resolution: "enquirer@npm:2.3.6" @@ -28932,6 +28954,15 @@ __metadata: languageName: node linkType: hard +"json2mq@npm:^0.2.0": + version: 0.2.0 + resolution: "json2mq@npm:0.2.0" + dependencies: + string-convert: ^0.2.0 + checksum: 5672c3abdd31e21a0e2f0c2688b4948103687dab949a1c5a1cba98667e899a96c2c7e3d71763c4f5e7cd7d7c379ea5dd5e1a9b2a2107dd1dfa740719a11aa272 + languageName: node + linkType: hard + "json5@npm:^0.5.0, json5@npm:^0.5.1": version: 0.5.1 resolution: "json5@npm:0.5.1" @@ -36968,6 +36999,22 @@ __metadata: languageName: node linkType: hard +"react-slick@npm:^0.30.2": + version: 0.30.2 + resolution: "react-slick@npm:0.30.2" + dependencies: + classnames: ^2.2.5 + enquire.js: ^2.1.6 + json2mq: ^0.2.0 + lodash.debounce: ^4.0.8 + resize-observer-polyfill: ^1.5.0 + peerDependencies: + react: ^0.14.0 || ^15.0.1 || ^16.0.0 || ^17.0.0 || ^18.0.0 + react-dom: ^0.14.0 || ^15.0.1 || ^16.0.0 || ^17.0.0 || ^18.0.0 + checksum: 7d415c745ca7d9d19947a6ce62ab866757310fbaf0941b14479cf2779f8db8951b037ab6a357a2d013518dd50fd41080dac7e7949cb201b4c6431f13c6e8975f + languageName: node + linkType: hard + "react-smooth@npm:^1.0.5": version: 1.0.6 resolution: "react-smooth@npm:1.0.6" @@ -39254,6 +39301,15 @@ __metadata: languageName: node linkType: hard +"slick-carousel@npm:^1.8.1": + version: 1.8.1 + resolution: "slick-carousel@npm:1.8.1" + peerDependencies: + jquery: ">=1.8.0" + checksum: acaad391e4d8bc1c7fdb8d361faa1f1d60829b31d618b54bc38c0550a59b26de36537e0ab4bc0364176ec11d1a61d0cf11e99d8d5b1285d656673c9a1a719257 + languageName: node + linkType: hard + "smart-buffer@npm:^4.2.0": version: 4.2.0 resolution: "smart-buffer@npm:4.2.0" @@ -39958,6 +40014,13 @@ __metadata: languageName: node linkType: hard +"string-convert@npm:^0.2.0": + version: 0.2.1 + resolution: "string-convert@npm:0.2.1" + checksum: 1098b1d8e3712c72d0a0b0b7f5c36c98af93e7660b5f0f14019e41bcefe55bfa79214d5e03e74d98a7334a0b9bf2b7f4c6889c8c24801aa2ae2f9ebe1d8a1ef9 + languageName: node + linkType: hard + "string-length@npm:^4.0.1": version: 4.0.1 resolution: "string-length@npm:4.0.1"