From 8e62aaa6dc7c61dcba7b9313d0aadcf7f46ce41b Mon Sep 17 00:00:00 2001 From: Husam Salhab <47015061+hsalhab@users.noreply.github.com> Date: Thu, 6 Aug 2020 16:11:11 -0400 Subject: [TMA-49] Add static boxes (#28) * adds BigInput component * removes dummy fields * adds website TaggInput * adds handleWebsiteUpdate() * added website regex * added form * added handleFocusChange() * sends website in request * moves input components to onboarding * allow for empty string in website regex * adds bio regex * adds bio field * added bioRef for focusChange * added react-native-datepicker * moves TaggInput * add imports * add TaggDatePicker * fix typescript interface * remove TouchableComponent type * added date and selectpicker * added date and dropdown * adds momentjs * remove warnings from optional fields * remove debugging console.log * Removes isValidBirthdate * moves @types/react-native-datepicker to devdepnden * update package versioning * fix positioning * added checkpoint * update button styling * update placeholder * linting and other fixes --- src/components/common/TaggInput.tsx | 62 ------ src/components/common/index.ts | 1 - src/components/onboarding/TaggBigInput.tsx | 64 ++++++ src/components/onboarding/TaggDatePicker.tsx | 63 ++++++ src/components/onboarding/TaggDropDown.tsx | 40 ++++ src/components/onboarding/TaggInput.tsx | 62 ++++++ src/components/onboarding/index.ts | 4 + src/constants/regex.ts | 22 ++ src/routes/onboarding/Onboarding.tsx | 6 + src/routes/onboarding/OnboardingStack.tsx | 1 + src/screens/onboarding/Checkpoint.tsx | 144 +++++++++++++ src/screens/onboarding/ProfileOnboarding.tsx | 310 +++++++++++++++++++++++---- src/screens/onboarding/Verification.tsx | 2 +- src/screens/onboarding/index.ts | 3 +- 14 files changed, 681 insertions(+), 103 deletions(-) delete mode 100644 src/components/common/TaggInput.tsx create mode 100644 src/components/onboarding/TaggBigInput.tsx create mode 100644 src/components/onboarding/TaggDatePicker.tsx create mode 100644 src/components/onboarding/TaggDropDown.tsx create mode 100644 src/components/onboarding/TaggInput.tsx create mode 100644 src/screens/onboarding/Checkpoint.tsx (limited to 'src') diff --git a/src/components/common/TaggInput.tsx b/src/components/common/TaggInput.tsx deleted file mode 100644 index fe11d4f0..00000000 --- a/src/components/common/TaggInput.tsx +++ /dev/null @@ -1,62 +0,0 @@ -import React from 'react'; -import {View, TextInput, StyleSheet, TextInputProps} from 'react-native'; -import * as Animatable from 'react-native-animatable'; - -interface TaggInputProps extends TextInputProps { - valid?: boolean; - invalidWarning?: string; - attemptedSubmit?: boolean; - width?: number | string; -} -/** - * An input component that receives all props a normal TextInput component does. TaggInput components grow to 60% of their parent's width by default, but this can be set using the `width` prop. - */ -const TaggInput = React.forwardRef((props: TaggInputProps, ref: any) => { - return ( - - - {props.attemptedSubmit && !props.valid && ( - - {props.invalidWarning} - - )} - - ); -}); - -const styles = StyleSheet.create({ - container: { - width: '100%', - alignItems: 'center', - marginVertical: 11, - }, - input: { - minWidth: '60%', - height: 40, - fontSize: 16, - fontWeight: '600', - color: '#fff', - borderColor: '#fffdfd', - borderWidth: 2, - borderRadius: 20, - paddingLeft: 13, - }, - warning: { - fontSize: 14, - marginTop: 5, - color: '#f4ddff', - maxWidth: 350, - textAlign: 'center', - }, -}); - -export default TaggInput; diff --git a/src/components/common/index.ts b/src/components/common/index.ts index 826675ff..cb8b9b6a 100644 --- a/src/components/common/index.ts +++ b/src/components/common/index.ts @@ -1,7 +1,6 @@ export {default as CenteredView} from './CenteredView'; export {default as OverlayView} from './OverlayView'; export {default as RadioCheckbox} from './RadioCheckbox'; -export {default as TaggInput} from './TaggInput'; export {default as NavigationIcon} from './NavigationIcon'; export {default as GradientBackground} from './GradientBackground'; export {default as Post} from './post'; diff --git a/src/components/onboarding/TaggBigInput.tsx b/src/components/onboarding/TaggBigInput.tsx new file mode 100644 index 00000000..ba965465 --- /dev/null +++ b/src/components/onboarding/TaggBigInput.tsx @@ -0,0 +1,64 @@ +import React from 'react'; +import {View, TextInput, StyleSheet, TextInputProps} from 'react-native'; +import * as Animatable from 'react-native-animatable'; + +interface TaggBigInputProps extends TextInputProps { + valid?: boolean; + invalidWarning?: string; + attemptedSubmit?: boolean; + width?: number | string; +} +/** + * An input component that receives all props a normal TextInput component does. TaggInput components grow to 60% of their parent's width by default, but this can be set using the `width` prop. + */ +const TaggBigInput = React.forwardRef((props: TaggBigInputProps, ref: any) => { + return ( + + + {props.attemptedSubmit && !props.valid && ( + + {props.invalidWarning} + + )} + + ); +}); + +const styles = StyleSheet.create({ + container: { + width: '100%', + alignItems: 'center', + marginVertical: 11, + }, + input: { + minWidth: '60%', + height: 120, + fontSize: 16, + fontWeight: '600', + color: '#fff', + borderColor: '#fffdfd', + borderWidth: 2, + borderRadius: 20, + paddingLeft: 13, + paddingTop: 13, + }, + warning: { + fontSize: 14, + marginTop: 5, + color: '#f4ddff', + maxWidth: 350, + textAlign: 'center', + }, +}); + +export default TaggBigInput; diff --git a/src/components/onboarding/TaggDatePicker.tsx b/src/components/onboarding/TaggDatePicker.tsx new file mode 100644 index 00000000..39af6234 --- /dev/null +++ b/src/components/onboarding/TaggDatePicker.tsx @@ -0,0 +1,63 @@ +import React from 'react'; +import DatePicker from 'react-native-datepicker'; +import {View, StyleSheet, TextInputProps} from 'react-native'; + +interface TaggDatePickerProps extends TextInputProps { + width?: number | string; + date?: string; + maxDate?: Date | string; +} +/** + * An input component that receives all props a normal TextInput component does. TaggInput components grow to 60% of their parent's width by default, but this can be set using the `width` prop. + */ +const TaggDatePicker = React.forwardRef( + (props: TaggDatePickerProps, ref: any) => { + return ( + + + + ); + }, +); + +const styles = StyleSheet.create({ + container: { + width: '100%', + alignItems: 'center', + marginVertical: 11, + }, + input: { + minWidth: '67%', + height: 40, + }, +}); + +export default TaggDatePicker; diff --git a/src/components/onboarding/TaggDropDown.tsx b/src/components/onboarding/TaggDropDown.tsx new file mode 100644 index 00000000..a45426ca --- /dev/null +++ b/src/components/onboarding/TaggDropDown.tsx @@ -0,0 +1,40 @@ +import React from 'react'; +import RNSelectPicker from 'react-native-picker-select'; +import {View, StyleSheet, TextInputProps} from 'react-native'; + +interface TaggDropDownProps extends TextInputProps { + width?: number | string; +} + +/** + * An input component that receives all props a normal TextInput component does. TaggInput components grow to 60% of their parent's width by default, but this can be set using the `width` prop. + */ +const TaggDropDown = React.forwardRef((props: TaggDropDownProps, ref: any) => { + return ( + + + + ); +}); + +const styles = StyleSheet.create({ + container: { + width: '66.67%', + alignItems: 'center', + marginVertical: 11, + }, + inputIOS: { + paddingVertical: 8, + paddingHorizontal: 10, + minWidth: '60%', + fontSize: 16, + fontWeight: '600', + color: '#fff', + borderColor: '#fffdfd', + borderWidth: 2, + borderRadius: 20, + paddingLeft: 13, + }, +}); + +export default TaggDropDown; diff --git a/src/components/onboarding/TaggInput.tsx b/src/components/onboarding/TaggInput.tsx new file mode 100644 index 00000000..fe11d4f0 --- /dev/null +++ b/src/components/onboarding/TaggInput.tsx @@ -0,0 +1,62 @@ +import React from 'react'; +import {View, TextInput, StyleSheet, TextInputProps} from 'react-native'; +import * as Animatable from 'react-native-animatable'; + +interface TaggInputProps extends TextInputProps { + valid?: boolean; + invalidWarning?: string; + attemptedSubmit?: boolean; + width?: number | string; +} +/** + * An input component that receives all props a normal TextInput component does. TaggInput components grow to 60% of their parent's width by default, but this can be set using the `width` prop. + */ +const TaggInput = React.forwardRef((props: TaggInputProps, ref: any) => { + return ( + + + {props.attemptedSubmit && !props.valid && ( + + {props.invalidWarning} + + )} + + ); +}); + +const styles = StyleSheet.create({ + container: { + width: '100%', + alignItems: 'center', + marginVertical: 11, + }, + input: { + minWidth: '60%', + height: 40, + fontSize: 16, + fontWeight: '600', + color: '#fff', + borderColor: '#fffdfd', + borderWidth: 2, + borderRadius: 20, + paddingLeft: 13, + }, + warning: { + fontSize: 14, + marginTop: 5, + color: '#f4ddff', + maxWidth: 350, + textAlign: 'center', + }, +}); + +export default TaggInput; diff --git a/src/components/onboarding/index.ts b/src/components/onboarding/index.ts index ef972194..31f356d3 100644 --- a/src/components/onboarding/index.ts +++ b/src/components/onboarding/index.ts @@ -3,3 +3,7 @@ export {default as Background} from './Background'; export {default as RegistrationWizard} from './RegistrationWizard'; export {default as TermsConditions} from './TermsConditions'; export {default as SubmitButton} from './SubmitButton'; +export {default as TaggInput} from './TaggInput'; +export {default as TaggBigInput} from './TaggBigInput'; +export {default as TaggDatePicker} from './TaggDatePicker'; +export {default as TaggDropDown} from './TaggDropDown'; diff --git a/src/constants/regex.ts b/src/constants/regex.ts index 40c82691..c380ee30 100644 --- a/src/constants/regex.ts +++ b/src/constants/regex.ts @@ -26,3 +26,25 @@ export const usernameRegex: RegExp = /^[a-zA-Z0-9_.]{6,30}$/; * - match alphanumerics, apostrophes, commas, periods, dashes, and spaces */ export const nameRegex: RegExp = /^[A-Za-z'\-,. ]{2,20}$/; + +/** + * The website regex has the following constraints + * - starts with http:// or https:// + * - min. 2 chars, max. 50 chars on website name + * - match alphanumerics, and special characters used in URLs + */ +export const websiteRegex: RegExp = /^$|^(https?:\/\/)?(www\.)?[-a-zA-Z0-9@:%._+~#=]{1,50}\.[a-zA-Z0-9()]{2,6}\b([-a-zA-Z0-9()@:%_+.~#?&/=]{0,35})$/; + +/** + * The website regex has the following constraints + * - max. 150 chars for bio + * - match alphanumerics, and special characters used in URLs + */ +export const bioRegex: RegExp = /^$|^[A-Za-z'\-,. ]{1,150}$/; + +/** + * The gender regex has the following constraints + * - max. 20 chars for bio + * - match alphanumerics, hyphens, and whitespaces + */ +export const genderRegex: RegExp = /^$|^[A-Za-z\- ]{2,20}$/; diff --git a/src/routes/onboarding/Onboarding.tsx b/src/routes/onboarding/Onboarding.tsx index d2bfbfd6..40dbc970 100644 --- a/src/routes/onboarding/Onboarding.tsx +++ b/src/routes/onboarding/Onboarding.tsx @@ -6,6 +6,7 @@ import { RegistrationTwo, Verification, ProfileOnboarding, + Checkpoint, } from '../../screens'; const Onboarding: React.FC = () => { @@ -26,6 +27,11 @@ const Onboarding: React.FC = () => { component={RegistrationTwo} options={{headerShown: false}} /> + ; +type CheckpointNavigationProp = StackNavigationProp< + RootStackParamList, + 'Checkpoint' +>; +interface CheckpointProps { + route: CheckpointRouteProp; + navigation: CheckpointNavigationProp; +} +/** + * Registration screen 2 for email, username, password, and terms and conditions + * @param navigation react-navigation navigation object + */ +const Checkpoint: React.FC = ({route, navigation}) => { + const {userId, username} = route.params; + + /** + * login: determines if user successully created an account to + * navigate to home and display main tab navigation bar + */ + const {login} = React.useContext(AuthContext); + + const handleSkip = () => { + login(userId, username); + }; + + const handleProceed = () => { + navigation.navigate('ProfileOnboarding', { + userId: userId, + username: username, + }); + }; + + return ( + + + + + + Email verified! + + We're almost there. Would you like to setup your profile now? + + + + Do it later + + + Let's do it! + + + + + ); +}; + +const styles = StyleSheet.create({ + container: { + flex: 1, + alignItems: 'center', + }, + textContainer: { + marginTop: '65%', + }, + + buttonContainer: { + flexDirection: 'row', + justifyContent: 'space-evenly', + }, + wizard: { + ...Platform.select({ + ios: { + top: 50, + }, + android: { + bottom: 40, + }, + }), + }, + header: { + color: '#fff', + fontSize: 22, + fontWeight: '600', + textAlign: 'center', + marginBottom: '4%', + marginHorizontal: '10%', + }, + subtext: { + color: '#fff', + fontSize: 14, + fontWeight: '600', + textAlign: 'center', + marginBottom: '16%', + marginHorizontal: '10%', + }, + proceedButton: { + backgroundColor: '#8F01FF', + justifyContent: 'center', + alignItems: 'center', + width: 150, + height: 40, + borderRadius: 5, + marginTop: '5%', + }, + proceedButtonLabel: { + fontSize: 16, + fontWeight: '500', + color: '#fff', + }, + skipButton: { + justifyContent: 'center', + alignItems: 'center', + width: 150, + height: 40, + borderRadius: 5, + borderWidth: 1, + borderColor: '#ddd', + marginTop: '5%', + }, + skipButtonLabel: { + fontSize: 16, + fontWeight: '500', + color: '#ddd', + }, +}); + +export default Checkpoint; diff --git a/src/screens/onboarding/ProfileOnboarding.tsx b/src/screens/onboarding/ProfileOnboarding.tsx index 9405ca52..ea045434 100644 --- a/src/screens/onboarding/ProfileOnboarding.tsx +++ b/src/screens/onboarding/ProfileOnboarding.tsx @@ -10,11 +10,23 @@ import { Alert, View, } from 'react-native'; +import { + Background, + TaggBigInput, + TaggInput, + TaggDatePicker, + TaggDropDown, +} from '../../components'; import {OnboardingStackParams} from '../../routes/onboarding'; import {AuthContext} from '../../routes/authentication'; -import {Background} from '../../components'; import ImagePicker from 'react-native-image-crop-picker'; -import {REGISTER_ENDPOINT} from '../../constants'; +import { + REGISTER_ENDPOINT, + websiteRegex, + bioRegex, + genderRegex, +} from '../../constants'; +import moment from 'moment'; type ProfileOnboardingScreenRouteProp = RouteProp< OnboardingStackParams, @@ -36,8 +48,51 @@ interface ProfileOnboardingProps { const ProfileOnboarding: React.FC = ({route}) => { const {userId, username} = route.params; - const [largePic, setLargePic] = React.useState(''); - const [smallPic, setSmallPic] = React.useState(''); + const [form, setForm] = React.useState({ + largePic: '', + smallPic: '', + website: '', + bio: '', + birthdate: '', + gender: '', + isValidWebsite: true, + isValidBio: true, + isValidGender: true, + attemptedSubmit: false, + }); + const [customGender, setCustomGender] = React.useState(); + + // refs for changing focus + const bioRef = React.useRef(); + const birthdateRef = React.useRef(); + const genderRef = React.useRef(); + const customGenderRef = React.useRef(); + /** + * Handles focus change to the next input field. + * @param field key for field to move focus onto + */ + const handleFocusChange = (field: string): void => { + switch (field) { + case 'bio': + const bioField: any = bioRef.current; + bioField.focus(); + break; + case 'birthdate': + const birthdateField: any = birthdateRef.current; + birthdateField.focus(); + break; + case 'gender': + const genderField: any = genderRef.current; + genderField.focus(); + break; + case 'customGender': + const customGenderField: any = customGenderRef.current; + customGenderField.focus(); + break; + default: + return; + } + }; /** * login: determines if user successully created an account to @@ -54,9 +109,9 @@ const ProfileOnboarding: React.FC = ({route}) => { accessibilityLabel="ADD LARGE PROFILE PIC HERE" onPress={goToGalleryLargePic} style={styles.largeProfile}> - {largePic ? ( + {form.largePic ? ( ) : ( @@ -74,9 +129,9 @@ const ProfileOnboarding: React.FC = ({route}) => { accessibilityLabel="ADD SMALLER PIC" onPress={goToGallerySmallPic} style={styles.smallProfile}> - {smallPic ? ( + {form.smallPic ? ( ) : ( @@ -99,7 +154,10 @@ const ProfileOnboarding: React.FC = ({route}) => { }) .then((picture) => { if ('path' in picture) { - setLargePic(picture.path); + setForm({ + ...form, + largePic: picture.path, + }); } }) .catch(() => {}); @@ -120,28 +178,140 @@ const ProfileOnboarding: React.FC = ({route}) => { }) .then((picture) => { if ('path' in picture) { - setSmallPic(picture.path); + setForm({ + ...form, + smallPic: picture.path, + }); } }) .catch(() => {}); }; + /* + * Handles changes to the website field value and verifies the input by updating state and running a validation function. + */ + const handleWebsiteUpdate = (website: string) => { + let isValidWebsite: boolean = websiteRegex.test(website); + setForm({ + ...form, + website, + isValidWebsite, + }); + }; + + /* + * Handles changes to the bio field value and verifies the input by updating state and running a validation function. + */ + const handleBioUpdate = (bio: string) => { + let isValidBio: boolean = bioRegex.test(bio); + setForm({ + ...form, + bio, + isValidBio, + }); + }; + + const handleGenderUpdate = (gender: string) => { + if (gender === 'custom') { + setCustomGender(true); + } else { + setCustomGender(false); + let isValidGender: boolean = true; + setForm({ + ...form, + gender, + isValidGender, + }); + } + }; + + const handleCustomGenderUpdate = (gender: string) => { + let isValidGender: boolean = genderRegex.test(gender); + gender = gender.replace(' ', '-'); + setForm({ + ...form, + gender, + isValidGender, + }); + }; + + const handleBirthdateUpdate = (birthdate: string) => { + setForm({ + ...form, + birthdate, + }); + }; + + const getMaxDate = () => { + const maxDate = moment().subtract(13, 'y').subtract(1, 'd'); + return maxDate.format('YYYY-MM-DD'); + }; + const handleSubmit = async () => { - const form = new FormData(); - if (largePic) { - form.append('largeProfilePicture', { - uri: largePic, + if (!form.attemptedSubmit) { + setForm({ + ...form, + attemptedSubmit: true, + }); + } + let invalidFields: boolean = false; + const request = new FormData(); + if (form.largePic) { + request.append('largeProfilePicture', { + uri: form.largePic, name: 'large_profile_pic.jpg', type: 'image/jpg', }); } - if (smallPic) { - form.append('smallProfilePicture', { - uri: smallPic, + if (form.smallPic) { + request.append('smallProfilePicture', { + uri: form.smallPic, name: 'small_profile_pic.jpg', type: 'image/jpg', }); } + if (form.website) { + if (form.isValidWebsite) { + request.append('website', form.website); + } else { + setForm({...form, attemptedSubmit: false}); + setTimeout(() => setForm({...form, attemptedSubmit: true})); + invalidFields = true; + } + } + + if (form.bio) { + if (form.isValidBio) { + request.append('biography', form.bio); + } else { + setForm({...form, attemptedSubmit: false}); + setTimeout(() => setForm({...form, attemptedSubmit: true})); + invalidFields = true; + } + } + + if (form.birthdate) { + request.append('birthday', form.birthdate); + } + + if (customGender) { + if (form.isValidGender) { + request.append('gender', form.gender); + } else { + setForm({...form, attemptedSubmit: false}); + setTimeout(() => setForm({...form, attemptedSubmit: true})); + invalidFields = true; + } + } else { + if (form.isValidGender) { + request.append('gender', form.gender); + } + } + + if (invalidFields) { + return; + } + const endpoint = REGISTER_ENDPOINT + `${userId}/`; try { let response = await fetch(endpoint, { @@ -149,7 +319,7 @@ const ProfileOnboarding: React.FC = ({route}) => { headers: { 'Content-Type': 'multipart/form-data', }, - body: form, + body: request, }); let statusCode = response.status; let data = await response.json(); @@ -178,14 +348,82 @@ const ProfileOnboarding: React.FC = ({route}) => { return ( - - - - DUMMY WEBSITE - - - DUMMY BIO + + + + handleFocusChange('bio')} + blurOnSubmit={false} + valid={form.isValidWebsite} + attemptedSubmit={form.attemptedSubmit} + invalidWarning={'Website must be a valid link to your website'} + width={280} + /> + handleFocusChange('bio')} + blurOnSubmit={false} + ref={bioRef} + valid={form.isValidBio} + attemptedSubmit={form.attemptedSubmit} + invalidWarning={ + 'Bio must be less than 150 characters and must contain valid characters' + } + width={280} + /> + handleBirthdateUpdate(birthdate)} + /> + handleGenderUpdate(value)} + items={[ + {label: 'Male', value: 'male'}, + {label: 'Female', value: 'female'}, + {label: 'Custom', value: 'custom'}, + ]} + placeholder={{ + label: 'Gender', + value: null, + color: '#ddd', + }} + /> + {customGender && ( + handleSubmit()} + valid={form.isValidGender} + attemptedSubmit={form.attemptedSubmit} + invalidWarning={'Custom field can only contain letters and hyphens'} + width={280} + /> + )} Let's start! @@ -194,6 +432,10 @@ const ProfileOnboarding: React.FC = ({route}) => { }; const styles = StyleSheet.create({ + profile: { + flexDirection: 'row', + marginBottom: '5%', + }, largeProfile: { justifyContent: 'center', alignItems: 'center', @@ -202,7 +444,8 @@ const styles = StyleSheet.create({ width: 230, borderRadius: 23, backgroundColor: '#fff', - marginRight: '6%', + marginLeft: '13%', + marginTop: '5%', }, largeProfileText: { textAlign: 'center', @@ -218,8 +461,8 @@ const styles = StyleSheet.create({ width: 110, borderRadius: 55, backgroundColor: '#E1F0FF', - marginLeft: '45%', - bottom: '7%', + right: '18%', + marginTop: '38%', }, smallProfileText: { textAlign: 'center', @@ -232,16 +475,6 @@ const styles = StyleSheet.create({ marginLeft: 0, bottom: 0, }, - dummyField: { - height: '10%', - width: '80%', - justifyContent: 'center', - alignItems: 'center', - borderColor: '#fff', - borderWidth: 1, - borderRadius: 8, - marginBottom: '10%', - }, submitBtn: { backgroundColor: '#8F01FF', justifyContent: 'center', @@ -249,6 +482,7 @@ const styles = StyleSheet.create({ width: 150, height: 40, borderRadius: 5, + marginTop: '5%', }, submitBtnLabel: { fontSize: 16, diff --git a/src/screens/onboarding/Verification.tsx b/src/screens/onboarding/Verification.tsx index 0676bb3a..7c74324a 100644 --- a/src/screens/onboarding/Verification.tsx +++ b/src/screens/onboarding/Verification.tsx @@ -59,7 +59,7 @@ const Verification: React.FC = ({route, navigation}) => { }); let statusCode = verifyOtpResponse.status; if (statusCode === 200) { - navigation.navigate('ProfileOnboarding', { + navigation.navigate('Checkpoint', { userId: userId, username: username, }); diff --git a/src/screens/onboarding/index.ts b/src/screens/onboarding/index.ts index 7a9816e7..e6627ca7 100644 --- a/src/screens/onboarding/index.ts +++ b/src/screens/onboarding/index.ts @@ -1,5 +1,6 @@ export {default as Login} from './Login'; -export {default as ProfileOnboarding} from './ProfileOnboarding'; export {default as RegistrationOne} from './RegistrationOne'; export {default as RegistrationTwo} from './RegistrationTwo'; export {default as Verification} from './Verification'; +export {default as Checkpoint} from './Checkpoint'; +export {default as ProfileOnboarding} from './ProfileOnboarding'; -- cgit v1.2.3-70-g09d2