Fakultas Ilmu Komputer UI

Commit 0ee0e390 authored by Wulan Mantiri's avatar Wulan Mantiri
Browse files

Integrate Dietela Quiz API, add cache storage and toast component

parent b10de3a5
......@@ -4,6 +4,7 @@ import android.app.Application;
import android.content.Context;
import com.facebook.react.PackageList;
import com.facebook.react.ReactApplication;
import com.reactnativecommunity.asyncstorage.AsyncStoragePackage;
import com.th3rdwave.safeareacontext.SafeAreaContextPackage;
import com.oblador.vectoricons.VectorIconsPackage;
import com.facebook.react.ReactInstanceManager;
......
rootProject.name = 'dietela_mobile'
include ':@react-native-community_async-storage'
project(':@react-native-community_async-storage').projectDir = new File(rootProject.projectDir, '../node_modules/@react-native-community/async-storage/android')
include ':react-native-safe-area-context'
project(':react-native-safe-area-context').projectDir = new File(rootProject.projectDir, '../node_modules/react-native-safe-area-context/android')
include ':react-native-vector-icons'
......
......@@ -12,6 +12,8 @@ target 'dietela_mobile' do
pod 'RNGestureHandler', :path => '../node_modules/react-native-gesture-handler'
pod 'RNCAsyncStorage', :path => '../node_modules/@react-native-community/async-storage'
target 'dietela_mobileTests' do
inherit! :complete
# Pods for testing
......
import { jest } from '@jest/globals';
import mockAsyncStorage from '@react-native-community/async-storage/jest/async-storage-mock';
jest.mock('react-native/Libraries/Animated/src/NativeAnimatedHelper');
jest.mock('@react-native-community/async-storage', () => mockAsyncStorage);
import React from 'react';
import { render } from '@testing-library/react-native';
import { HeaderLeft } from './styles';
import { HeaderLeft, ErrorToast } from './styles';
import App from '.';
describe('Application', () => {
......@@ -12,4 +12,8 @@ describe('Application', () => {
test('header left button renders correctly', () => {
render(<HeaderLeft />);
});
test('error toast renders correctly', () => {
render(<ErrorToast text1NumberOfLines={2} text2NumberOfLines={2} />);
});
});
......@@ -4,12 +4,13 @@ import { NavigationContainer } from '@react-navigation/native';
import { createStackNavigator } from '@react-navigation/stack';
import { ThemeProvider } from 'react-native-elements';
import { SafeAreaProvider } from 'react-native-safe-area-context';
import Toast from 'react-native-toast-message';
import * as ROUTES from 'constants/routes';
import { navigation } from 'constants/navigation';
import { theme } from 'styles/theme';
import { screenOptions } from './styles';
import { screenOptions, toastConfig } from './styles';
const Stack = createStackNavigator();
......@@ -34,6 +35,7 @@ const App: FC = () => {
))}
</Stack.Navigator>
</NavigationContainer>
<Toast config={toastConfig} ref={Toast.setRef} />
</ThemeProvider>
</SafeAreaProvider>
);
......
import React from 'react';
import { StyleSheet } from 'react-native';
import {
HeaderBackButton,
StackHeaderLeftButtonProps,
StackNavigationOptions,
} from '@react-navigation/stack';
import { colors, typographyStyles } from 'styles';
import { BaseToast, BaseToastProps } from 'react-native-toast-message';
import { colors, typographyStyles, typography } from 'styles';
export const HeaderLeft = (props: StackHeaderLeftButtonProps) => (
<HeaderBackButton {...props} />
......@@ -24,3 +26,29 @@ export const screenOptions: StackNavigationOptions = {
headerTitleAlign: 'center',
headerLeft: HeaderLeft,
};
const styles = StyleSheet.create({
toastStyle: { borderLeftColor: 'red' },
contentContainerStyle: { padding: 16 },
text1Style: {
...typography.bodyMedium,
color: 'red',
},
});
export const ErrorToast = (props: BaseToastProps) => (
<BaseToast
{...props}
style={styles.toastStyle}
contentContainerStyle={styles.contentContainerStyle}
text1Style={styles.text1Style}
text2Style={{
...props.text2Style,
...typography.caption,
}}
/>
);
export const toastConfig = {
error: ErrorToast,
};
......@@ -11,15 +11,16 @@ const NextButton: FC<NextButtonProps> = ({ goNext }) => (
icon={{
name: 'caretright',
type: 'antdesign',
color: colors.primary,
color: colors.textBlack,
size: 20,
}}
title="Lanjut"
type="clear"
onPress={goNext}
iconRight
titleStyle={{
color: colors.primary,
color: colors.textBlack,
}}
buttonStyle={styles.nextButton}
/>
);
......@@ -29,6 +30,7 @@ const WizardContainer: FC<Props> = ({
setCurrentStep,
finishButtonLabel,
onFinish,
isLoading,
}) => {
const goBack = () => {
setCurrentStep(currentStep - 1);
......@@ -39,7 +41,7 @@ const WizardContainer: FC<Props> = ({
};
return (
<ScrollView>
<ScrollView contentContainerStyle={styles.container}>
{components[currentStep - 1]}
{currentStep > 1 ? (
<View style={styles.bottomContainer}>
......@@ -47,20 +49,26 @@ const WizardContainer: FC<Props> = ({
icon={{
name: 'caretleft',
type: 'antdesign',
color: colors.primary,
color: 'gray',
size: 20,
}}
title="Kembali"
type="clear"
onPress={goBack}
titleStyle={{
color: colors.primary,
color: colors.textBlack,
}}
buttonStyle={styles.backButton}
/>
{currentStep === components.length ? (
<Button
title={finishButtonLabel || 'Selesai'}
onPress={onFinish}
buttonStyle={styles.finishButton}
loading={isLoading}
titleStyle={{
color: colors.textBlack,
}}
/>
) : (
<NextButton goNext={goNext} />
......
import { StyleSheet } from 'react-native';
import { colors } from 'styles';
import { colors, layoutStyles } from 'styles';
export const styles = StyleSheet.create({
container: {
display: 'flex',
flexDirection: 'column',
justifyContent: 'space-between',
flexGrow: 1,
},
bottomContainer: {
display: 'flex',
flexDirection: 'row',
justifyContent: 'space-between',
padding: 10,
...layoutStyles,
},
flexEnd: {
display: 'flex',
flexDirection: 'row',
justifyContent: 'flex-end',
padding: 10,
...layoutStyles,
},
backButton: {
paddingLeft: 0,
},
nextButton: {
width: 140,
justifyContent: 'space-between',
backgroundColor: colors.primaryYellow,
borderRadius: 20,
paddingRight: 10,
},
finishButton: {
backgroundColor: colors.primary,
paddingHorizontal: 20,
marginRight: 10,
borderRadius: 30,
width: 140,
backgroundColor: colors.secondaryVariant,
},
});
......@@ -4,6 +4,7 @@ export interface Props {
components: ReactNode[];
currentStep: number;
finishButtonLabel?: string;
isLoading?: boolean;
onFinish: () => void;
setCurrentStep: (_: number) => void;
}
......
export { default as BigButton } from './Button';
export { default as WizardContainer } from './WizardContainer';
export { default as Toast } from 'react-native-toast-message';
......@@ -30,7 +30,7 @@ const MultipleCheckbox: FC<Props> = ({
Pertanyaan {questionNumber} / {totalQuestions}
</Text>
<Text style={[typographyStyles.headingLarge, styles.spacing]}>
{questionLabel}
{questionLabel} <Text style={styles.red}>*</Text>
</Text>
<Text
style={[
......
import React, { FC } from 'react';
import { Input } from 'react-native-elements';
import { styles } from './styles';
import { Props } from './types';
import FormLabel from '../FormLabel';
const TextField: FC<Props> = ({ label, required, ...props }) => (
<Input {...props} label={<FormLabel label={label} required={required} />} />
const TextField: FC<Props> = ({ label, required, errorMessage, ...props }) => (
<Input
{...props}
label={<FormLabel label={label} required={required} />}
errorStyle={[
styles.red,
errorMessage ? styles.bigMargin : styles.smallMargin,
]}
errorMessage={errorMessage}
/>
);
export default TextField;
import { StyleSheet } from 'react-native';
export const styles = StyleSheet.create({
red: {
color: 'red',
},
smallMargin: {
height: 10,
},
bigMargin: {
height: 30,
},
});
export default {
dietProfileId: 'DIET_PROFILE_ID',
};
import React from 'react';
import { render, fireEvent, waitFor } from 'utils/testing';
import * as ROUTES from 'constants/routes';
import axios from 'axios';
import AllAccessQuestionnaire from '.';
import { allAccessQuestions, textFields } from './schema';
jest.mock('react-native-toast-message');
jest.mock('axios');
const mockAxios = axios as jest.Mocked<typeof axios>;
describe('AllAccessQuestionnaire', () => {
const validFormValues: { [_: string]: any } = {
name: 'Dietela',
......@@ -12,6 +17,21 @@ describe('AllAccessQuestionnaire', () => {
age: '29',
weight: '82',
height: '178',
gender: 1,
special_condition: 1,
body_activity: 1,
vegetables_in_one_day: 1,
fruits_in_one_day: 1,
fried_food_in_one_day: 1,
sweet_snacks_in_one_day: 1,
sweet_drinks_in_one_day: 1,
packaged_food_in_one_day: 1,
large_meal_in_one_day: 1,
snacks_in_one_day: 1,
breakfast_type: 1,
current_condition: 1,
problem_to_solve: 1,
health_problem: [1],
};
it('shows biodata form and all required errors after submit with empty form values', async () => {
......@@ -30,15 +50,15 @@ describe('AllAccessQuestionnaire', () => {
expect(submitButton).toBeTruthy();
await waitFor(() => fireEvent.press(submitButton));
const preQuizHeader = queryByText(/Data Diri/i);
await waitFor(() => expect(preQuizHeader).toBeTruthy());
const preQuizHeader = getByText(/Data Diri/i);
expect(preQuizHeader).toBeTruthy();
textFields.forEach(({ label }) => {
const errorMessage = queryByText(`${label} harus diisi`);
const errorMessage = getByText(`${label} harus diisi`);
expect(errorMessage).toBeTruthy();
});
const genderErrorMessage = queryByText('Pilihan harus diisi');
const genderErrorMessage = getByText('Pilihan harus diisi');
expect(genderErrorMessage).toBeTruthy();
const sampleNextPage = queryByText(/Pertanyaan/i);
......@@ -46,7 +66,7 @@ describe('AllAccessQuestionnaire', () => {
});
it('shows the foremost page with error after submit', async () => {
const { getByText, getByPlaceholderText, queryByText } = render(
const { getByText, getByPlaceholderText } = render(
<AllAccessQuestionnaire />,
ROUTES.allAccessQuestionnaire,
);
......@@ -67,11 +87,57 @@ describe('AllAccessQuestionnaire', () => {
const submitButton = getByText('Selesai');
await waitFor(() => fireEvent.press(submitButton));
const nextPage = queryByText(/Pertanyaan/i);
await waitFor(() => expect(nextPage).toBeTruthy());
const nextPage = getByText(/Pertanyaan/i);
expect(nextPage).toBeTruthy();
});
it('redirects to initial page if all form values are valid and submit success', async () => {
const createDietProfileApi = () =>
Promise.resolve({
status: 201,
data: {
id: 1,
...validFormValues,
},
});
mockAxios.request.mockImplementationOnce(createDietProfileApi);
const { getByText, getByPlaceholderText } = render(
<AllAccessQuestionnaire />,
ROUTES.allAccessQuestionnaire,
);
textFields.forEach(({ name, placeholder }) => {
const formField = getByPlaceholderText(placeholder as string);
fireEvent.changeText(formField, validFormValues[name]);
});
const maleChoice = getByText(/Pria/i);
fireEvent.press(maleChoice);
allAccessQuestions.forEach(({ choiceList }) => {
const nextButton = getByText(/Lanjut/i);
fireEvent.press(nextButton);
const firstChoice = getByText(choiceList[0]);
fireEvent.press(firstChoice);
});
const submitButton = getByText('Selesai');
await waitFor(() => fireEvent.press(submitButton));
const initialPage = getByText(/konsultasi sekarang/i);
expect(initialPage).toBeTruthy();
});
it('redirects to initial page if all form values are valid', async () => {
it('does not redirect to initial page if all form values are valid but submit fails', async () => {
const createDietProfileApi = () =>
Promise.reject({
status: 400,
data: 'error',
});
mockAxios.request.mockImplementationOnce(createDietProfileApi);
const { getByText, getByPlaceholderText, queryByText } = render(
<AllAccessQuestionnaire />,
ROUTES.allAccessQuestionnaire,
......@@ -97,6 +163,6 @@ describe('AllAccessQuestionnaire', () => {
await waitFor(() => fireEvent.press(submitButton));
const initialPage = queryByText(/konsultasi sekarang/i);
await waitFor(() => expect(initialPage).toBeTruthy());
expect(initialPage).toBeFalsy();
});
});
import React, { FC, useState } from 'react';
import { View } from 'react-native';
import AsyncStorage from '@react-native-community/async-storage';
import { useNavigation } from '@react-navigation/native';
import { WizardContainer } from 'components/core';
import { WizardContainer, Toast } from 'components/core';
import { MultipleChoice, MultipleCheckbox } from 'components/form';
import CACHE_KEYS from 'constants/cacheKeys';
import * as ROUTES from 'constants/routes';
import { useForm } from 'hooks';
import { createDietProfileApi } from 'services/dietelaQuiz';
import { layoutStyles } from 'styles';
import { BiodataForm } from './components';
......@@ -15,6 +18,7 @@ import {
textFields,
radioButtonGroups,
fieldValidations,
convertPayload,
} from './schema';
import { generateValidationSchema } from 'utils/form';
......@@ -29,11 +33,25 @@ const AllAccessQuestionnaire: FC = () => {
validateForm,
getFirstErrorIndex,
handleSubmit,
isSubmitting,
} = useForm({
initialValues,
validationSchema: generateValidationSchema(fieldValidations),
onSubmit: () => {
navigation.navigate(ROUTES.initial);
onSubmit: async (values) => {
const response = await createDietProfileApi(convertPayload(values));
if (response.success) {
await AsyncStorage.setItem(
CACHE_KEYS.dietProfileId,
`${response.data?.id}`,
);
navigation.navigate(ROUTES.initial);
} else {
Toast.show({
type: 'error',
text1: 'Gagal menyimpan data',
text2: 'Terjadi kesalahan pada sisi kami. Silakan coba lagi',
});
}
},
});
......@@ -86,6 +104,7 @@ const AllAccessQuestionnaire: FC = () => {
}),
]}
onFinish={onSubmit}
isLoading={isSubmitting}
/>
);
};
......
import { FieldValidation, FieldType } from 'utils/form';
import { TextFieldSchema, RadioButtonGroupSchema } from 'types/form';
import { DietProfileRequest } from 'services/dietelaQuiz/models';
export const textFields: TextFieldSchema[] = [
{
......@@ -16,21 +17,21 @@ export const textFields: TextFieldSchema[] = [
},
{
label: 'Usia (tahun)',
placeholder: '20',
placeholder: 'Ex: 20',
required: true,
name: 'age',
keyboardType: 'numeric',
},
{
label: 'Berat badan (kg)',
placeholder: '60',
placeholder: 'Ex: 60',
required: true,
name: 'weight',
keyboardType: 'numeric',
},
{
label: 'Tinggi badan (cm)',
placeholder: '160',
placeholder: 'Ex: 160',
required: true,
name: 'height',
keyboardType: 'numeric',
......@@ -279,3 +280,12 @@ export const fieldValidations: FieldValidation[] = [
type: field.multiple ? FieldType.CHECKBOX : FieldType.RADIO_BUTTON,
})),
];
export const convertPayload = (
values: typeof initialValues,
): DietProfileRequest => ({
...values,
age: parseInt(values.age, 10),
height: parseInt(values.age, 10),
weight: parseInt(values.age, 10),
});
import axios, { AxiosRequestConfig } from 'axios';
export enum RequestMethod {
GET = 'GET',
POST = 'POST',
PUT = 'PUT',
DELETE = 'DELETE',
}
export async function api(
method: RequestMethod = RequestMethod.GET,
url: string,
body: object = {},
headers: object = {},
): Promise<{
success: boolean;
data?: any;
error?: any;
}> {
const requestData: AxiosRequestConfig = {
url: `https://dietela-backend.herokuapp.com/${url}`,
method,
data: JSON.stringify(body),
headers,
};
return await axios
.request(requestData)
.then((res) => ({
success: true,
data: res.data,
}))
.catch((err) => ({
success: false,
<