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 (
+
+ );
+}
+
+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 (
+
+ );
+}
+
+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