Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions app/src/components/LoadingDialog.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import React from 'react';
import { StyleSheet } from 'react-native';
import { Text, Dialog } from '@rneui/themed';

const LoadingDialog = ({ isVisible = false, loadingText = null }) => {
return (
<Dialog testID="loading-dialog" isVisible={isVisible} style={styles.dialogLoadingContainer}>
<Dialog.Loading />
{loadingText && (
<Text style={styles.dialogLoadingText} testID="loading-dialog-text">
{loadingText}
</Text>
)}
</Dialog>
);
};

const styles = StyleSheet.create({
dialogLoadingContainer: {
flex: 1,
},
dialogLoadingText: {
textAlign: 'center',
fontStyle: 'italic',
},
});

export default LoadingDialog;
22 changes: 22 additions & 0 deletions app/src/components/__tests__/LoadingDialog.mock.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import React from 'react';
import { render } from '@testing-library/react-native';
import LoadingDialog from '../LoadingDialog';

const mockLoadingDialog = jest.fn();

jest.mock('../LoadingDialog', () => ({ isVisible, loadingText }) => {
mockLoadingDialog(isVisible, loadingText);
return <mock-LoadingDialog />;
});

describe('LoadingDialog', () => {
test('should render the LoadingDialog with the correct props and styles', () => {
render(<LoadingDialog isVisible={true} loadingText="Loading..." />);
expect(mockLoadingDialog.mock.calls[0]).toEqual([true, 'Loading...']);
});

test('should show the loadingText prop in the dialog', () => {
render(<LoadingDialog loadingText="Loading..." />);
expect(mockLoadingDialog.mock.calls[0][1]).toEqual('Loading...');
});
});
39 changes: 39 additions & 0 deletions app/src/components/__tests__/LoadingDialog.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import React from 'react';
import { render } from '@testing-library/react-native';
import LoadingDialog from '../LoadingDialog';

describe('LoadingDialog', () => {
test('should be hidden when isVisible prop is false', () => {
const wrapper = render(<LoadingDialog />);

const loadingDialogElement = wrapper.getByTestId('loading-dialog');
expect(loadingDialogElement.props.visible).toBe(false);
});

test('should be visible when isVisible prop is true', () => {
const wrapper = render(<LoadingDialog isVisible={true} />);

const loadingDialogElement = wrapper.getByTestId('loading-dialog');
expect(loadingDialogElement.props.visible).toBe(true);
});

test('should show the LoadingDialog with text', () => {
const wrapper = render(<LoadingDialog isVisible={true} loadingText="Loading..." />);

const iconElement = wrapper.getByTestId('Dialog__Loading');
expect(iconElement).toBeTruthy();

const textElement = wrapper.getByTestId('loading-dialog-text');
expect(textElement.props.children).toEqual('Loading...');
});

test('should show the LoadingDialog without text', () => {
const wrapper = render(<LoadingDialog isVisible={true} />);

const iconElement = wrapper.getByTestId('Dialog__Loading');
expect(iconElement).toBeTruthy();

const textElement = wrapper.queryByTestId('loading-dialog-text');
expect(textElement).toBeFalsy();
});
});
1 change: 1 addition & 0 deletions app/src/components/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,4 +4,5 @@ export { default as Card } from './Card';
export { default as Image } from './Image';
export { default as CenterLayout } from './CenterLayout';
export { default as LogoutButton } from './LogoutButton';
export { default as LoadingDialog } from './LoadingDialog';
export { default as NetworkStatusBar } from './NetworkStatusBar';
94 changes: 70 additions & 24 deletions app/src/navigation/__tests__/index.test.js
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
import React from 'react';
import { render, act } from '@testing-library/react-native';
import { render, act, waitFor } from '@testing-library/react-native';
import { NavigationContainer } from '@react-navigation/native';
import { AuthState, UIState } from '../../store';
import { BackHandler } from 'react-native';
import { AuthState, UIState, UserState } from '../../store';
import mockBackHandler from 'react-native/Libraries/Utilities/__mocks__/BackHandler.js';

import Navigation from '../index';
import { backgroundTask, notification } from '../../lib';
import * as Notifications from 'expo-notifications';
import { crudForms } from '../../database/crud';

jest.mock('expo-background-fetch', () => ({
...jest.requireActual('expo-background-fetch'),
Expand All @@ -19,13 +21,20 @@ jest.mock('expo-background-fetch', () => ({

jest.mock('expo-notifications', () => ({
...jest.requireActual('expo-notifications'),
addNotificationReceivedListener: jest.fn(),
addNotificationResponseReceivedListener: jest.fn(),
addNotificationReceivedListener: jest.fn(() => ({
remove: jest.fn(),
})),
addNotificationResponseReceivedListener: jest.fn(() => ({
remove: jest.fn(),
})),
removeNotificationSubscription: jest.fn(),
getPermissionsAsync: jest.fn(() => Promise.resolve({ status: 'denied' })),
}));

jest.mock('../..//lib/background-task', () => ({
syncFormVersion: jest.fn(),
jest.mock('@react-navigation/native-stack');

jest.mock('../../lib/background-task', () => ({
syncFormVersion: jest.fn(() => Promise.resolve([])),
backgroundTaskStatus: jest.fn(),
}));

Expand All @@ -34,17 +43,24 @@ jest.mock('../../lib/notification', () => ({
registerForPushNotificationsAsync: jest.fn(),
}));

jest.mock('../../database/crud', () => ({
crudForms: {
selectLatestFormVersion: jest.fn(() => Promise.resolve([])),
selectFormById: jest.fn(() => Promise.resolve([])),
selectFormByIdAndVersion: jest.fn(() => Promise.resolve([])),
addForm: jest.fn(() => Promise.resolve({ insertId: null })),
updateForm: jest.fn(() => Promise.resolve({ rowsAffected: 1 })),
},
}));
jest.mock('react-native/Libraries/Utilities/BackHandler', () => mockBackHandler);

describe('Navigation Component', () => {
const mockAddEventListener = jest.fn(() => {
remove: jest.fn();
});
const mockRemoveEventListener = jest.fn(() => {
remove: jest.fn();
beforeAll(() => {
UserState.update((s) => {
s.id = null;
});
});

BackHandler.addEventListener = mockAddEventListener;
BackHandler.removeEventListener = mockRemoveEventListener;

afterEach(() => {
jest.clearAllMocks();
});
Expand All @@ -55,8 +71,9 @@ describe('Navigation Component', () => {
<Navigation />
</NavigationContainer>,
);

const mockAddEventListener = jest.fn();
act(() => {
mockAddEventListener();
UIState.update((s) => {
s.currentPage = 'GetStarted';
});
Expand All @@ -65,21 +82,50 @@ describe('Navigation Component', () => {
});
});

expect(BackHandler.addEventListener).toHaveBeenCalledTimes(1);
expect(mockAddEventListener).toHaveBeenCalledTimes(1);
unmount();
});

it('should call set up notification function', () => {
const { unmount } = render(
it('should call set up notification function', async () => {
render(
<NavigationContainer>
<Navigation />
</NavigationContainer>,
);

expect(backgroundTask.backgroundTaskStatus).toHaveBeenCalledTimes(2);
expect(notification.registerForPushNotificationsAsync).toHaveBeenCalledTimes(1);
expect(Notifications.addNotificationReceivedListener).toHaveBeenCalledTimes(1);
expect(Notifications.addNotificationResponseReceivedListener).toHaveBeenCalledTimes(1);
unmount();
await waitFor(() => {
expect(backgroundTask.backgroundTaskStatus).toHaveBeenCalledTimes(2);
expect(notification.registerForPushNotificationsAsync).toHaveBeenCalledTimes(1);
expect(Notifications.addNotificationReceivedListener).toHaveBeenCalledTimes(1);
expect(Notifications.addNotificationResponseReceivedListener).toHaveBeenCalledTimes(1);
});
});

it('should be able to sync form version', async () => {
render(
<NavigationContainer>
<Navigation />
</NavigationContainer>,
);
Notifications.getPermissionsAsync.mockImplementation(() =>
Promise.resolve({ status: 'granted' }),
);

Notifications.scheduleNotificationAsync({
content: {
title: 'New Form version available',
body: 'Here is the notification body',
data: null,
},
trigger: null,
});

await act(async () => {
await backgroundTask.syncFormVersion();
});

await waitFor(() => {
expect(backgroundTask.syncFormVersion).toHaveBeenCalledTimes(1);
});
});
});
61 changes: 33 additions & 28 deletions app/src/navigation/index.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import React from 'react';
import React, { useState, useEffect } from 'react';
import { NavigationContainer, useNavigationContainerRef } from '@react-navigation/native';
import { createNativeStackNavigator } from '@react-navigation/native-stack';
import {
Expand All @@ -20,7 +20,9 @@ import { BackHandler } from 'react-native';
import * as TaskManager from 'expo-task-manager';
import * as BackgroundFetch from 'expo-background-fetch';
import * as Notifications from 'expo-notifications';
import { backgroundTask, notification } from '../lib';
import { backgroundTask, notification, i18n } from '../lib';
import { LoadingDialog } from '../components';
import { crudForms } from '../database/crud';

const SYNC_FORM_VERSION_TASK_NAME = 'sync-form-version';
const SYNC_FORM_SUBMISSION_TASK_NAME = 'sync-form-submission';
Expand Down Expand Up @@ -58,14 +60,16 @@ TaskManager.defineTask(SYNC_FORM_SUBMISSION_TASK_NAME, async () => {

const Stack = createNativeStackNavigator();

const RootNavigator = () => {
const RootNavigator = ({ setIsSyncForm }) => {
const preventHardwareBackPressFormPages = ['Home', 'AddUser'];
const currentPage = UIState.useState((s) => s.currentPage);
const token = AuthState.useState((s) => s.token); // user already has session
const userDefined = UserState.useState((s) => s.id);
const syncInterval = BuildParamsState.useState((s) => s.dataSyncInterval);
const activeLang = UIState.useState((s) => s.lang);
const trans = i18n.text(activeLang);

React.useEffect(() => {
useEffect(() => {
const backHandler = BackHandler.addEventListener('hardwareBackPress', () => {
if (!token || !preventHardwareBackPressFormPages.includes(currentPage)) {
// Allow navigation if user is not logged in
Expand All @@ -77,17 +81,34 @@ const RootNavigator = () => {
return () => backHandler.remove();
}, [token, currentPage]);

React.useEffect(() => {
useEffect(() => {
backgroundTask.backgroundTaskStatus(SYNC_FORM_VERSION_TASK_NAME);
backgroundTask.backgroundTaskStatus(SYNC_FORM_SUBMISSION_TASK_NAME, syncInterval);

notification.registerForPushNotificationsAsync();
const notificationListener = Notifications.addNotificationReceivedListener((notification) => {
const notificationListener = Notifications.addNotificationReceivedListener(() => {
console.log('[Notification]Received Listener');
});
const responseListener = Notifications.addNotificationResponseReceivedListener((response) => {
const responseListener = Notifications.addNotificationResponseReceivedListener(() => {
console.log('[Notification]Response Listener');
backgroundTask.syncFormVersion({ showNotificationOnly: false });
setIsSyncForm(true);
backgroundTask.syncFormVersion({ showNotificationOnly: false }).then(() => {
crudForms.selectLatestFormVersion().then((results) => {
const forms = results.map((r) => ({
...r,
subtitles: [
`${trans.versionLabel}${r.version}`,
`${trans.submittedLabel}${r.submitted}`,
`${trans.draftLabel}${r.draft}`,
`${trans.syncLabel}${r.synced}`,
],
}));
FormState.update((s) => {
s.allForms = forms;
});
setIsSyncForm(false);
});
});
});
return () => {
Notifications.removeNotificationSubscription(notificationListener);
Expand Down Expand Up @@ -123,28 +144,12 @@ const RootNavigator = () => {
};

const Navigation = (props) => {
const navigationRef = useNavigationContainerRef();

const handleOnChangeNavigation = (state) => {
// listen to route change
const currentRoute = state.routes[state.routes.length - 1].name;
if (['Home', 'ManageForm'].includes(currentRoute)) {
// reset form values
FormState.update((s) => {
s.currentValues = {};
s.questionGroupListCurrentValues = {};
s.visitedQuestionGroup = [];
s.surveyDuration = 0;
});
}
UIState.update((s) => {
s.currentPage = currentRoute;
});
};
const [isSyncForm, setIsSyncForm] = useState(false);

return (
<NavigationContainer ref={navigationRef} onStateChange={handleOnChangeNavigation} {...props}>
<RootNavigator />
<NavigationContainer {...props}>
<RootNavigator setIsSyncForm={setIsSyncForm} />
<LoadingDialog isVisible={isSyncForm} loadingText="Updating form, please wait." />
</NavigationContainer>
);
};
Expand Down
15 changes: 3 additions & 12 deletions app/src/pages/AuthForm.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,12 @@ import React from 'react';
import Icon from 'react-native-vector-icons/Ionicons';
import { Asset } from 'expo-asset';
import { View, StyleSheet, Platform, ToastAndroid } from 'react-native';
import { Input, Button, Text, Dialog } from '@rneui/themed';
import { Input, Button, Text } from '@rneui/themed';
import { CenterLayout, Image } from '../components';
import { api, cascades, i18n } from '../lib';
import { AuthState, UserState, UIState } from '../store';
import { crudSessions, crudForms, crudUsers, crudConfig } from '../database/crud';
import { LoadingDialog } from '../components';

const ToggleEye = ({ hidden, onPress }) => {
const iconName = hidden ? 'eye' : 'eye-off';
Expand Down Expand Up @@ -139,10 +140,7 @@ const AuthForm = ({ navigation }) => {
{trans.buttonLogin}
</Button>
{/* Loading dialog */}
<Dialog isVisible={loading} style={styles.dialogLoadingContainer}>
<Dialog.Loading />
<Text style={styles.dialogLoadingText}>{trans.fetchingData}</Text>
</Dialog>
<LoadingDialog isVisible={loading} loadingText="{trans.fetchingData}" />
</CenterLayout>
);
};
Expand All @@ -159,13 +157,6 @@ const styles = StyleSheet.create({
marginLeft: 8,
},
errorText: { color: 'red', fontStyle: 'italic', marginHorizontal: 10, marginTop: -8 },
dialogLoadingContainer: {
flex: 1,
},
dialogLoadingText: {
textAlign: 'center',
fontStyle: 'italic',
},
});

export default AuthForm;
Loading