Skip to content

Commit

Permalink
Add token timer support
Browse files Browse the repository at this point in the history
  • Loading branch information
fongsean committed Sep 4, 2023
1 parent 92fdf10 commit e5aa6fe
Show file tree
Hide file tree
Showing 11 changed files with 468 additions and 4 deletions.
4 changes: 2 additions & 2 deletions apps/smart-forms-app/src/api/headers.ts
Original file line number Diff line number Diff line change
@@ -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'
};
6 changes: 4 additions & 2 deletions apps/smart-forms-app/src/contexts/SmartClientContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ export interface SmartClientState {
user: Practitioner | null;
encounter: Encounter | null;
launchQuestionnaire: Questionnaire | null;
tokenReceivedTimestamp: number | null;
}

export type SmartClientActions =
Expand All @@ -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,
Expand All @@ -59,7 +60,8 @@ const initialSmartClientState: SmartClientState = {
patient: null,
user: null,
encounter: null,
launchQuestionnaire: null
launchQuestionnaire: null,
tokenReceivedTimestamp: null
};

export interface SmartClientContextType {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -71,6 +72,7 @@ const RendererHeader = memo(function RendererHeader(props: RendererHeaderProps)
<Box flexGrow={1} />

<UpdatingIndicator />
<TokenTimer />

<HeaderIcons />
</StyledToolbar>
Expand Down
Original file line number Diff line number Diff line change
@@ -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 (
<Dialog open={true}>
<DialogTitle variant="h5" sx={{ m: 2 }}>
Autosaving...
</DialogTitle>
</Dialog>
);
}

export default AutoSaveDialog;
Original file line number Diff line number Diff line change
@@ -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<number | null>(null);
const [reminderOpen, setReminderOpen] = useState(false);
const [hasReminded, setHasReminded] = useState(false);
const [autoSaveStatus, setAutoSaveStatus] = useState<AutoSaveStatus>('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 (
<>
<TokenTimerIndicator
showRemainingTime={showRemainingTime}
timeLeft={timeLeft}
isAutoSaving={autoSaveStatus === 'saving'}
/>
{reminderOpen ? (
<TokenTimerDialog
open={reminderOpen && !hasReminded}
closeDialog={() => {
setReminderOpen(false);
setHasReminded(true);
setAutoSaveStatus('shouldNotSave');
}}
/>
) : null}

{isAutoSaving ? <AutoSaveDialog onAutoSave={() => setAutoSaveStatus('saved')} /> : null}
</>
);
});

export default TokenTimer;
Original file line number Diff line number Diff line change
@@ -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: (
<Tooltip title="View Responses">
<IconButton
color="inherit"
onClick={() => {
navigate(
launchQuestionnaireExists ? '/dashboard/existing' : '/dashboard/responses'
);
closeSnackbar();
}}>
<ReadMoreIcon />
</IconButton>
</Tooltip>
)
});

// 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 (
<Dialog open={open}>
<DialogTitle variant="h5">Heads up, you have 15 minutes left</DialogTitle>
<DialogContent>
<DialogContentText variant="body1">
{
'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.'
}
</DialogContentText>
</DialogContent>
<DialogActions>
<Button onClick={handleClose}>Cancel</Button>
<LoadingButton loading={isSaving} onClick={handleSave}>
Save as Draft
</LoadingButton>
</DialogActions>
</Dialog>
);
}

export default TokenTimerDialog;
Loading

0 comments on commit e5aa6fe

Please sign in to comment.