diff --git a/apps/smart-forms-app/src/api/headers.ts b/apps/smart-forms-app/src/api/headers.ts index b6cfd5ecc..66fea61a4 100644 --- a/apps/smart-forms-app/src/api/headers.ts +++ b/apps/smart-forms-app/src/api/headers.ts @@ -1,4 +1,4 @@ export const HEADERS = { - 'Content-Type': 'application/json;charset=utf-8', - Accept: 'application/json;charset=utf-8' + 'Content-Type': 'application/json+fhir;charset=utf-8', + Accept: 'application/json+fhir;charset=utf-8' }; diff --git a/apps/smart-forms-app/src/contexts/SmartClientContext.tsx b/apps/smart-forms-app/src/contexts/SmartClientContext.tsx index f3d44c9a0..6e863e1a8 100644 --- a/apps/smart-forms-app/src/contexts/SmartClientContext.tsx +++ b/apps/smart-forms-app/src/contexts/SmartClientContext.tsx @@ -26,6 +26,7 @@ export interface SmartClientState { user: Practitioner | null; encounter: Encounter | null; launchQuestionnaire: Questionnaire | null; + tokenReceivedTimestamp: number | null; } export type SmartClientActions = @@ -39,7 +40,7 @@ export type SmartClientActions = function smartClientReducer(state: SmartClientState, action: SmartClientActions): SmartClientState { switch (action.type) { case 'SET_CLIENT': - return { ...state, smartClient: action.payload }; + return { ...state, smartClient: action.payload, tokenReceivedTimestamp: Date.now() }; case 'SET_COMMON_CONTEXTS': return { ...state, @@ -59,7 +60,8 @@ const initialSmartClientState: SmartClientState = { patient: null, user: null, encounter: null, - launchQuestionnaire: null + launchQuestionnaire: null, + tokenReceivedTimestamp: null }; export interface SmartClientContextType { diff --git a/apps/smart-forms-app/src/features/renderer/components/RendererHeader/RendererHeader.tsx b/apps/smart-forms-app/src/features/renderer/components/RendererHeader/RendererHeader.tsx index f91df3e6f..39d7ce1e2 100644 --- a/apps/smart-forms-app/src/features/renderer/components/RendererHeader/RendererHeader.tsx +++ b/apps/smart-forms-app/src/features/renderer/components/RendererHeader/RendererHeader.tsx @@ -26,6 +26,7 @@ import { StyledRoot, StyledToolbar } from '../../../../components/Header/Header. import { memo } from 'react'; import HeaderIcons from '../../../../components/Header/HeaderIcons.tsx'; import { useQuestionnaireStore } from '@aehrc/smart-forms-renderer'; +import TokenTimer from '../../../tokenTimer/components/TokenTimer.tsx'; interface RendererHeaderProps { navIsCollapsed: boolean; @@ -71,6 +72,7 @@ const RendererHeader = memo(function RendererHeader(props: RendererHeaderProps) + diff --git a/apps/smart-forms-app/src/features/tokenTimer/components/AutoSaveDialog.tsx b/apps/smart-forms-app/src/features/tokenTimer/components/AutoSaveDialog.tsx new file mode 100644 index 000000000..e67fae28a --- /dev/null +++ b/apps/smart-forms-app/src/features/tokenTimer/components/AutoSaveDialog.tsx @@ -0,0 +1,87 @@ +/* + * Copyright 2023 Commonwealth Scientific and Industrial Research + * Organisation (CSIRO) ABN 41 687 119 230. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { useEffect } from 'react'; +import { Dialog, DialogTitle } from '@mui/material'; +import { + removeHiddenAnswersFromResponse, + useQuestionnaireResponseStore, + useQuestionnaireStore +} from '@aehrc/smart-forms-renderer'; +import cloneDeep from 'lodash.clonedeep'; +import { saveQuestionnaireResponse } from '../../../api/saveQr.ts'; +import { useSnackbar } from 'notistack'; +import useSmartClient from '../../../hooks/useSmartClient.ts'; + +interface AutoSaveDialogProps { + onAutoSave: () => void; +} + +function AutoSaveDialog(props: AutoSaveDialogProps) { + const { onAutoSave } = props; + + const { smartClient, patient, user } = useSmartClient(); + + const sourceQuestionnaire = useQuestionnaireStore((state) => state.sourceQuestionnaire); + const updatableResponse = useQuestionnaireResponseStore((state) => state.updatableResponse); + const setUpdatableResponseAsSaved = useQuestionnaireResponseStore( + (state) => state.setUpdatableResponseAsSaved + ); + + const { enqueueSnackbar, closeSnackbar } = useSnackbar(); + + useEffect(() => { + handleAutoSave(); + }, []); + + function handleAutoSave() { + closeSnackbar(); + if (!(smartClient && patient && user)) { + return; + } + + const responseToSave = removeHiddenAnswersFromResponse( + sourceQuestionnaire, + cloneDeep(updatableResponse) + ); + + responseToSave.status = 'in-progress'; + saveQuestionnaireResponse(smartClient, patient, user, sourceQuestionnaire, responseToSave) + .then((savedResponse) => { + setUpdatableResponseAsSaved(savedResponse); + enqueueSnackbar('Response saved as draft', { + variant: 'success' + }); + onAutoSave(); + }) + .catch((error) => { + console.error(error); + enqueueSnackbar('An error occurred while saving.', { variant: 'error' }); + onAutoSave(); + }); + } + + return ( + + + Autosaving... + + + ); +} + +export default AutoSaveDialog; diff --git a/apps/smart-forms-app/src/features/tokenTimer/components/TokenTimer.tsx b/apps/smart-forms-app/src/features/tokenTimer/components/TokenTimer.tsx new file mode 100644 index 000000000..8615821bc --- /dev/null +++ b/apps/smart-forms-app/src/features/tokenTimer/components/TokenTimer.tsx @@ -0,0 +1,95 @@ +/* + * Copyright 2023 Commonwealth Scientific and Industrial Research + * Organisation (CSIRO) ABN 41 687 119 230. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { memo, useEffect, useState } from 'react'; +import useSmartClient from '../../../hooks/useSmartClient.ts'; +import { calculateRemainingTime, getTokenExpirationTime } from '../utils/tokenTimer.ts'; +import TokenTimerDialog from './TokenTimerDialog.tsx'; +import TokenTimerIndicator from './TokenTimerIndicator.tsx'; +import AutoSaveDialog from './AutoSaveDialog.tsx'; +import type { AutoSaveStatus } from '../types/autosave.ts'; + +const TokenTimer = memo(function TokenTimer() { + const { tokenReceivedTimestamp, smartClient } = useSmartClient(); + + const tokenExpirationTimeInSeconds = getTokenExpirationTime(smartClient); // Expiration time of the token in seconds + const [timeLeft, setTimeLeft] = useState(null); + const [reminderOpen, setReminderOpen] = useState(false); + const [hasReminded, setHasReminded] = useState(false); + const [autoSaveStatus, setAutoSaveStatus] = useState('shouldSave'); + + const reminderTime = 900; // 15 minutes = 900 seconds + const autoSaveTime = 300; // 5 minutes = 300 seconds + + // Set up an interval to periodically check the remaining time + useEffect( + () => { + const intervalId = setInterval(checkRemainingTime, 1000); // Check every minute (60000 milliseconds) + + // Clean up the interval when the component unmounts + return () => clearInterval(intervalId); + }, + // initialise interval for one time + // eslint-disable-next-line react-hooks/exhaustive-deps + [] + ); + + function checkRemainingTime() { + const remaining = calculateRemainingTime(tokenReceivedTimestamp, tokenExpirationTimeInSeconds); + if (!remaining) { + return null; + } + + if (remaining <= reminderTime) { + // 15 minutes = 900 seconds + setReminderOpen(true); + setTimeLeft(remaining); + + if (remaining <= autoSaveTime && autoSaveStatus === 'shouldSave') { + setReminderOpen(false); + } + } + } + + const showRemainingTime = typeof timeLeft === 'number' && timeLeft <= reminderTime; + const isAutoSaving = + typeof timeLeft === 'number' && timeLeft <= autoSaveTime && autoSaveStatus === 'shouldSave'; + + return ( + <> + + {reminderOpen ? ( + { + setReminderOpen(false); + setHasReminded(true); + setAutoSaveStatus('shouldNotSave'); + }} + /> + ) : null} + + {isAutoSaving ? setAutoSaveStatus('saved')} /> : null} + + ); +}); + +export default TokenTimer; diff --git a/apps/smart-forms-app/src/features/tokenTimer/components/TokenTimerDialog.tsx b/apps/smart-forms-app/src/features/tokenTimer/components/TokenTimerDialog.tsx new file mode 100644 index 000000000..5748f9cfe --- /dev/null +++ b/apps/smart-forms-app/src/features/tokenTimer/components/TokenTimerDialog.tsx @@ -0,0 +1,139 @@ +/* + * Copyright 2023 Commonwealth Scientific and Industrial Research + * Organisation (CSIRO) ABN 41 687 119 230. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { useState } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { useSnackbar } from 'notistack'; +import cloneDeep from 'lodash.clonedeep'; +import { + Button, + Dialog, + DialogActions, + DialogContent, + DialogContentText, + DialogTitle, + IconButton, + Tooltip +} from '@mui/material'; +import { LoadingButton } from '@mui/lab'; +import { + removeHiddenAnswersFromResponse, + useQuestionnaireResponseStore, + useQuestionnaireStore +} from '@aehrc/smart-forms-renderer'; +import ReadMoreIcon from '@mui/icons-material/ReadMore'; +import { saveQuestionnaireResponse } from '../../../api/saveQr.ts'; +import useSmartClient from '../../../hooks/useSmartClient.ts'; + +export interface TokenTimerDialogProps { + open: boolean; + closeDialog: () => unknown; +} + +function TokenTimerDialog(props: TokenTimerDialogProps) { + const { open, closeDialog } = props; + + const { smartClient, patient, user, launchQuestionnaire } = useSmartClient(); + + const sourceQuestionnaire = useQuestionnaireStore((state) => state.sourceQuestionnaire); + const updatableResponse = useQuestionnaireResponseStore((state) => state.updatableResponse); + const setUpdatableResponseAsSaved = useQuestionnaireResponseStore( + (state) => state.setUpdatableResponseAsSaved + ); + + const [isSaving, setIsSaving] = useState(false); + + const navigate = useNavigate(); + + const { enqueueSnackbar, closeSnackbar } = useSnackbar(); + + const launchQuestionnaireExists = !!launchQuestionnaire; + + // Event Handlers + function handleClose() { + closeDialog(); + } + + function handleSave() { + closeSnackbar(); + if (!(smartClient && patient && user)) { + return; + } + + setIsSaving(true); + const responseToSave = removeHiddenAnswersFromResponse( + sourceQuestionnaire, + cloneDeep(updatableResponse) + ); + + responseToSave.status = 'in-progress'; + saveQuestionnaireResponse(smartClient, patient, user, sourceQuestionnaire, responseToSave) + .then((savedResponse) => { + setUpdatableResponseAsSaved(savedResponse); + enqueueSnackbar('Response saved as draft', { + variant: 'success', + action: ( + + { + navigate( + launchQuestionnaireExists ? '/dashboard/existing' : '/dashboard/responses' + ); + closeSnackbar(); + }}> + + + + ) + }); + + // Wait until renderer.hasChanges is set to false before navigating away + setTimeout(() => { + setIsSaving(false); + handleClose(); + }, 500); + }) + .catch((error) => { + console.error(error); + enqueueSnackbar('An error occurred while saving. Try again later.', { + variant: 'error' + }); + }); + } + + return ( + + Heads up, you have 15 minutes left + + + { + 'You have 15 minutes left in your session. Do you want to save your progress so far as a draft? You would be unable to save your progress after the session expires.' + } + + + + + + Save as Draft + + + + ); +} + +export default TokenTimerDialog; diff --git a/apps/smart-forms-app/src/features/tokenTimer/components/TokenTimerIndicator.tsx b/apps/smart-forms-app/src/features/tokenTimer/components/TokenTimerIndicator.tsx new file mode 100644 index 000000000..cde29bd0d --- /dev/null +++ b/apps/smart-forms-app/src/features/tokenTimer/components/TokenTimerIndicator.tsx @@ -0,0 +1,59 @@ +/* + * Copyright 2023 Commonwealth Scientific and Industrial Research + * Organisation (CSIRO) ABN 41 687 119 230. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Typography } from '@mui/material'; +import { formatDisplayTime } from '../utils/formatDisplayTime.ts'; +import useResponsive from '../../../hooks/useResponsive.ts'; + +interface TokenTimerIndicatorProps { + showRemainingTime: boolean; + timeLeft: number | null; + isAutoSaving: boolean; +} + +function TokenTimerIndicator(props: TokenTimerIndicatorProps) { + const { showRemainingTime, timeLeft, isAutoSaving } = props; + + const isDesktop = useResponsive('up', 'lg'); + + if (!showRemainingTime || !timeLeft) { + return null; + } + + return ( + + {getIndicatorText(isAutoSaving, timeLeft)} + + ); +} + +function getIndicatorText(isAutoSaving: boolean, timeLeft: number) { + if (isAutoSaving) { + return 'Autosaving...'; + } + + if (timeLeft < 0) { + return 'Session ended'; + } + + return formatDisplayTime(timeLeft); +} +export default TokenTimerIndicator; diff --git a/apps/smart-forms-app/src/features/tokenTimer/types/autosave.ts b/apps/smart-forms-app/src/features/tokenTimer/types/autosave.ts new file mode 100644 index 000000000..4727c7cab --- /dev/null +++ b/apps/smart-forms-app/src/features/tokenTimer/types/autosave.ts @@ -0,0 +1,18 @@ +/* + * Copyright 2023 Commonwealth Scientific and Industrial Research + * Organisation (CSIRO) ABN 41 687 119 230. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export type AutoSaveStatus = 'shouldSave' | 'shouldNotSave' | 'saving' | 'saved'; diff --git a/apps/smart-forms-app/src/features/tokenTimer/utils/formatDisplayTime.ts b/apps/smart-forms-app/src/features/tokenTimer/utils/formatDisplayTime.ts new file mode 100644 index 000000000..215503398 --- /dev/null +++ b/apps/smart-forms-app/src/features/tokenTimer/utils/formatDisplayTime.ts @@ -0,0 +1,25 @@ +/* + * Copyright 2023 Commonwealth Scientific and Industrial Research + * Organisation (CSIRO) ABN 41 687 119 230. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export function formatDisplayTime(timeInSeconds: number): string { + const minutes = Math.floor(timeInSeconds / 60); + const remainingSeconds = timeInSeconds % 60; + + const remainingSecondsString = + remainingSeconds < 10 ? `0${remainingSeconds}` : `${remainingSeconds}`; + return `${minutes}:${remainingSecondsString}`; +} diff --git a/apps/smart-forms-app/src/features/tokenTimer/utils/tokenTimer.ts b/apps/smart-forms-app/src/features/tokenTimer/utils/tokenTimer.ts new file mode 100644 index 000000000..07cc0f61c --- /dev/null +++ b/apps/smart-forms-app/src/features/tokenTimer/utils/tokenTimer.ts @@ -0,0 +1,35 @@ +/* + * Copyright 2023 Commonwealth Scientific and Industrial Research + * Organisation (CSIRO) ABN 41 687 119 230. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import type Client from 'fhirclient/lib/Client'; + +export function getTokenExpirationTime(smartClient: Client | null): number | null { + return smartClient?.state.tokenResponse?.expires_in ?? null; +} + +export function calculateRemainingTime( + tokenReceivedTimestamp: number | null, + tokenExpirationTimeInSeconds: number | null +): number | null { + if (!tokenReceivedTimestamp || !tokenExpirationTimeInSeconds) { + return null; + } + + const currentTime = Date.now(); + const elapsedTimeInSeconds = Math.floor((currentTime - tokenReceivedTimestamp) / 1000); + return tokenExpirationTimeInSeconds - elapsedTimeInSeconds; +} diff --git a/apps/smart-forms-app/src/hooks/useSmartClient.ts b/apps/smart-forms-app/src/hooks/useSmartClient.ts index 25a823ae8..8923c1772 100644 --- a/apps/smart-forms-app/src/hooks/useSmartClient.ts +++ b/apps/smart-forms-app/src/hooks/useSmartClient.ts @@ -76,6 +76,7 @@ function useSmartClient() { const user = state.user; const encounter = state.encounter; const launchQuestionnaire = state.launchQuestionnaire; + const tokenReceivedTimestamp = state.tokenReceivedTimestamp; return { smartClient, @@ -83,6 +84,7 @@ function useSmartClient() { user, encounter, launchQuestionnaire, + tokenReceivedTimestamp, setSmartClient, setCommonLaunchContexts, setQuestionnaireLaunchContext