feat(register): ask for date of birth and place of study
This commit is contained in:
17 files changed
+609
-24
No files matched your search
@@ -15,6 +15,8 @@ Remove items from this list once they are done.
|
||||
Sentry needs ProGuard keep rules
|
||||
- verify the SDK 58 upgrade on devices: an expiry reminder notification and an error that lands
|
||||
in Bugsink, on Android and an iOS EAS build
|
||||
- check the registration form's birth date wheel on iOS (`src/components/DateField.tsx`), only
|
||||
tested on Android so far
|
||||
- verify the Stripe Checkout purchase flow end to end (needs galtinn deployed with
|
||||
`/api/stripe/checkout-session/app/` first): a test-mode purchase on Android and iOS with `4242…`,
|
||||
a card that requires 3D Secure and a cancel, checking the app lands back on the proof with the
|
||||
@@ -26,10 +28,6 @@ Remove items from this list once they are done.
|
||||
endpoint and its `payment_intent.succeeded` handling
|
||||
- test in ios emulator? (`npm run ios`, requires macOS)
|
||||
- characters in the password input field are invisible
|
||||
- ask for date of birth and place of study when registering: galtinn now requires them on its web
|
||||
sign-up and profile forms, but `/api/user/register/` still treats them as read-only, so users
|
||||
registered in the app must fill them in before they can save their profile on the web. Needs
|
||||
galtinn's register serializer to accept them first
|
||||
- use [Expo Application Services](https://expo.dev/eas) to build and submit app to stores
|
||||
- explore Expo features:
|
||||
- add an iOS 26 liquid glass icon: a `.icon` file made with Apple's Icon Composer,
|
||||
|
||||
Generated
+29
@@ -9,6 +9,7 @@
|
||||
"version": "2.0.1",
|
||||
"dependencies": {
|
||||
"@date-fns/tz": "^1.5.0",
|
||||
"@expo/ui": "~58.0.25",
|
||||
"@react-native-async-storage/async-storage": "2.2.0",
|
||||
"@react-native-vector-icons/feather": "^14.0.0",
|
||||
"@react-navigation/bottom-tabs": "^7.21.0",
|
||||
@@ -2872,6 +2873,34 @@
|
||||
"integrity": "sha512-HHQigo3rQWKMDzYDLkubN5WQOYXJJE2eNqIQC2axC2iO3mHdwnIR7FgZVvHWtBwAdzBgAP0ECp8KqS8TiMKvgw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@expo/ui": {
|
||||
"version": "58.0.25",
|
||||
"resolved": "https://registry.npmjs.org/@expo/ui/-/ui-58.0.25.tgz",
|
||||
"integrity": "sha512-nBYoFNTWWI8/LZPPVBs1kxQZxyaTKx0YfJx13VlFOu7YUtaUXGE5ivSf7OGVvO2qio+J2godvKy+8bgwKVnz1A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"sf-symbols-typescript": "^2.1.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@babel/core": "*",
|
||||
"expo": "*",
|
||||
"react": "*",
|
||||
"react-dom": "*",
|
||||
"react-native": "*",
|
||||
"react-native-worklets": "*"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@babel/core": {
|
||||
"optional": true
|
||||
},
|
||||
"react-dom": {
|
||||
"optional": true
|
||||
},
|
||||
"react-native-worklets": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@expo/ws-tunnel": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@expo/ws-tunnel/-/ws-tunnel-2.0.1.tgz",
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@date-fns/tz": "^1.5.0",
|
||||
"@expo/ui": "~58.0.25",
|
||||
"@react-native-async-storage/async-storage": "2.2.0",
|
||||
"@react-native-vector-icons/feather": "^14.0.0",
|
||||
"@react-navigation/bottom-tabs": "^7.21.0",
|
||||
|
||||
+32
-1
@@ -2,6 +2,7 @@ import {
|
||||
deleteUser,
|
||||
fetchEvents,
|
||||
fetchMembershipTypes,
|
||||
fetchPlacesOfStudy,
|
||||
fetchUserData,
|
||||
login,
|
||||
registerUser,
|
||||
@@ -23,6 +24,8 @@ const registerFormValues = {
|
||||
last_name: 'Testersen',
|
||||
email: 'grisen@example.test',
|
||||
phone_number: '+4712345678',
|
||||
date_of_birth: '2001-02-03',
|
||||
place_of_study: '7',
|
||||
password: 'hunter2',
|
||||
};
|
||||
|
||||
@@ -127,8 +130,10 @@ describe('registerUser', () => {
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
'https://dusken.example.test/api/user/register/',
|
||||
expect.objectContaining({ method: 'POST', body: JSON.stringify(registerFormValues) }),
|
||||
expect.objectContaining({ method: 'POST', body: expect.any(String) }),
|
||||
);
|
||||
// The place of study goes out as galtinn's numeric primary key
|
||||
expect(JSON.parse(fetchMock.mock.calls[0][1].body)).toEqual({ ...registerFormValues, place_of_study: 7 });
|
||||
});
|
||||
|
||||
it('throws an ApiError carrying field errors', async () => {
|
||||
@@ -187,6 +192,32 @@ describe('fetchEvents', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('fetchPlacesOfStudy', () => {
|
||||
it('posts the GraphQL query and resolves the places of study', async () => {
|
||||
const placesOfStudy = [{ id: '7', name: 'Universitetet i Oslo', shortName: 'UiO' }];
|
||||
mockResponseOnce({ data: { placesOfStudy } });
|
||||
|
||||
await expect(fetchPlacesOfStudy()).resolves.toEqual(placesOfStudy);
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
'https://dusken.example.test/api/graphql/',
|
||||
expect.objectContaining({
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ query: '{ placesOfStudy { id name shortName } }' }),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['a GraphQL error', { data: null, errors: [{ message: 'Cannot query field "placesOfStudy"' }] }, true],
|
||||
['a non-ok response', {}, false],
|
||||
])('throws on %s', async (_case, json, ok) => {
|
||||
mockResponseOnce(json, ok);
|
||||
|
||||
await expect(fetchPlacesOfStudy()).rejects.toThrow('Kunne ikke hente studiesteder');
|
||||
});
|
||||
});
|
||||
|
||||
describe('fetchMembershipTypes', () => {
|
||||
it('posts the GraphQL query and resolves the membership types', async () => {
|
||||
const membershipTypes = [{ slug: 'regular', price: 30000 }];
|
||||
|
||||
+26
-2
@@ -1,5 +1,5 @@
|
||||
import { ApiError } from '@/errors';
|
||||
import type { EventType, MembershipTypeOption, UserType } from '@/types/data';
|
||||
import type { EventType, MembershipTypeOption, PlaceOfStudy, UserType } from '@/types/data';
|
||||
import type { UserRegisterFormValues } from '@/types/forms';
|
||||
import { fetchWithTimeout } from '@/utils';
|
||||
|
||||
@@ -91,6 +91,8 @@ export async function registerUser({
|
||||
last_name,
|
||||
email,
|
||||
phone_number,
|
||||
date_of_birth,
|
||||
place_of_study,
|
||||
password,
|
||||
}: UserRegisterFormValues): Promise<{ user: UserType; token: string }> {
|
||||
const json = await requestJson(
|
||||
@@ -98,7 +100,15 @@ export async function registerUser({
|
||||
{
|
||||
method: 'POST',
|
||||
headers: JSON_HEADERS,
|
||||
body: JSON.stringify({ first_name, last_name, email, phone_number, password }),
|
||||
body: JSON.stringify({
|
||||
first_name,
|
||||
last_name,
|
||||
email,
|
||||
phone_number,
|
||||
date_of_birth,
|
||||
place_of_study: Number(place_of_study),
|
||||
password,
|
||||
}),
|
||||
},
|
||||
'Kunne ikke registrere bruker, prøv igjen',
|
||||
);
|
||||
@@ -141,6 +151,20 @@ export async function fetchMembershipTypes(): Promise<MembershipTypeOption[]> {
|
||||
return membershipTypes;
|
||||
}
|
||||
|
||||
export async function fetchPlacesOfStudy(): Promise<PlaceOfStudy[]> {
|
||||
const response = await fetchWithTimeout(`${process.env.EXPO_PUBLIC_DUSKEN_API_URL}/api/graphql/`, {
|
||||
method: 'POST',
|
||||
headers: JSON_HEADERS,
|
||||
body: JSON.stringify({ query: '{ placesOfStudy { id name shortName } }' }),
|
||||
});
|
||||
const json = await response.json();
|
||||
const placesOfStudy = response.ok ? json?.data?.placesOfStudy : null;
|
||||
if (!placesOfStudy) {
|
||||
throw new Error('Kunne ikke hente studiesteder');
|
||||
}
|
||||
return placesOfStudy;
|
||||
}
|
||||
|
||||
/** Create a Stripe Checkout session and return its URL; rejects with a message for the failure toast. */
|
||||
export async function requestStripeCheckoutSession(authToken: string, membershipType: string): Promise<string> {
|
||||
const response = await fetchWithTimeout(
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
import { DateTimePicker } from '@expo/ui/community/datetime-picker';
|
||||
import { format, parseISO } from 'date-fns';
|
||||
import React, { useState } from 'react';
|
||||
import { Platform } from 'react-native';
|
||||
|
||||
import SelectField from '@/components/SelectField';
|
||||
import theme from '@/theme';
|
||||
|
||||
type DateFieldProps = {
|
||||
label: string;
|
||||
/** yyyy-MM-dd, or empty while nothing is chosen */
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
placeholder?: string;
|
||||
/** Where the picker starts while nothing is chosen */
|
||||
initialPickerDate: Date;
|
||||
minimumDate?: Date;
|
||||
maximumDate?: Date;
|
||||
error?: string;
|
||||
};
|
||||
|
||||
/** A date field that opens the native date picker: a dialog on Android, a wheel below the field on iOS */
|
||||
const DateField = ({
|
||||
label,
|
||||
value,
|
||||
onChange,
|
||||
placeholder = 'Velg dato',
|
||||
initialPickerDate,
|
||||
minimumDate,
|
||||
maximumDate,
|
||||
error,
|
||||
}: DateFieldProps) => {
|
||||
const [open, setOpen] = useState(false);
|
||||
const date = value ? parseISO(value) : initialPickerDate;
|
||||
const choose = (chosen: Date) => onChange(format(chosen, 'yyyy-MM-dd'));
|
||||
|
||||
return (
|
||||
<>
|
||||
<SelectField
|
||||
label={label}
|
||||
valueText={value ? format(date, 'dd.MM.yyyy') : undefined}
|
||||
placeholder={placeholder}
|
||||
icon="calendar"
|
||||
onPress={() => {
|
||||
// The iOS wheel starts at initialPickerDate, so tapping it open without scrolling picks that date
|
||||
if (Platform.OS === 'ios' && !open && !value) {
|
||||
choose(initialPickerDate);
|
||||
}
|
||||
setOpen(!open);
|
||||
}}
|
||||
error={error}
|
||||
active={open}
|
||||
/>
|
||||
{open &&
|
||||
(Platform.OS === 'android' ? (
|
||||
// The dialog opens on mount, so unmount it once the user has answered. It follows the system's
|
||||
// light or dark theme, so it keeps Material's own colours: the brand blue is unreadable on dark
|
||||
<DateTimePicker
|
||||
value={date}
|
||||
minimumDate={minimumDate}
|
||||
maximumDate={maximumDate}
|
||||
positiveButton={{ label: 'OK' }}
|
||||
negativeButton={{ label: 'Avbryt' }}
|
||||
onValueChange={(_event, chosen) => {
|
||||
setOpen(false);
|
||||
choose(chosen);
|
||||
}}
|
||||
onDismiss={() => setOpen(false)}
|
||||
/>
|
||||
) : (
|
||||
<DateTimePicker
|
||||
value={date}
|
||||
minimumDate={minimumDate}
|
||||
maximumDate={maximumDate}
|
||||
display="spinner"
|
||||
locale="nb_NO"
|
||||
// Drawn inside the app's own light screen, so the brand blue reads fine
|
||||
accentColor={theme.colors.chateauBlue}
|
||||
onValueChange={(_event, chosen) => choose(chosen)}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default DateField;
|
||||
@@ -40,11 +40,11 @@ const FormInput = ({
|
||||
returnKeyType={returnKeyType}
|
||||
onChangeText={onChange}
|
||||
onBlur={onBlur}
|
||||
fieldStyle={fieldError ? styles.fieldWithError : undefined}
|
||||
fieldStyle={fieldError ? formFieldStyles.fieldWithError : undefined}
|
||||
value={value}
|
||||
{...rest}
|
||||
/>
|
||||
{fieldError && <Text style={styles.errorMessage}>{fieldError.message?.toString()}</Text>}
|
||||
{fieldError && <Text style={formFieldStyles.errorMessage}>{fieldError.message?.toString()}</Text>}
|
||||
</>
|
||||
)}
|
||||
name={name}
|
||||
@@ -53,7 +53,8 @@ const FormInput = ({
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
// Shared with SelectField's error state
|
||||
export const formFieldStyles = StyleSheet.create({
|
||||
fieldWithError: {
|
||||
borderColor: theme.colors.danger,
|
||||
},
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import Icon from '@react-native-vector-icons/feather/static';
|
||||
import React, { ComponentProps } from 'react';
|
||||
import { Pressable, StyleSheet, Text, View } from 'react-native';
|
||||
|
||||
import { formFieldStyles } from '@/components/FormInput';
|
||||
import { textFieldStyles } from '@/components/TextField';
|
||||
import theme from '@/theme';
|
||||
|
||||
type SelectFieldProps = {
|
||||
label: string;
|
||||
/** The chosen value as shown to the user; the placeholder shows while it is empty */
|
||||
valueText?: string;
|
||||
placeholder: string;
|
||||
icon: ComponentProps<typeof Icon>['name'];
|
||||
onPress: () => void;
|
||||
error?: string;
|
||||
/** Highlights the field like a focused TextField, e.g. while its picker is open */
|
||||
active?: boolean;
|
||||
};
|
||||
|
||||
/** A tappable field that opens a picker, styled to match TextField */
|
||||
const SelectField = ({ label, valueText, placeholder, icon, onPress, error, active }: SelectFieldProps) => (
|
||||
<View style={textFieldStyles.container}>
|
||||
<Text style={textFieldStyles.label}>{label}</Text>
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={label}
|
||||
accessibilityValue={{ text: valueText ?? '' }}
|
||||
onPress={onPress}
|
||||
style={[
|
||||
textFieldStyles.input,
|
||||
styles.row,
|
||||
active && textFieldStyles.inputFocused,
|
||||
error ? formFieldStyles.fieldWithError : undefined,
|
||||
]}
|
||||
>
|
||||
<Text style={[styles.text, !valueText && styles.placeholder]} numberOfLines={1}>
|
||||
{valueText || placeholder}
|
||||
</Text>
|
||||
<Icon name={icon} size={18} color={theme.colors.chateauBlue} />
|
||||
</Pressable>
|
||||
{error && <Text style={formFieldStyles.errorMessage}>{error}</Text>}
|
||||
</View>
|
||||
);
|
||||
|
||||
export default SelectField;
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
row: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
},
|
||||
text: {
|
||||
flex: 1,
|
||||
color: theme.colors.chateauBlue,
|
||||
fontSize: 16,
|
||||
fontWeight: '500',
|
||||
},
|
||||
// TextField's placeholder colour: the text colour at 60%
|
||||
placeholder: {
|
||||
color: `${theme.colors.chateauBlue}99`,
|
||||
},
|
||||
});
|
||||
@@ -27,12 +27,16 @@ const TextField = React.forwardRef<TextInputInstance, TextFieldProps>(
|
||||
const [focused, setFocused] = useState(false);
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<View style={textFieldStyles.container}>
|
||||
{label != null &&
|
||||
(typeof label === 'string' || typeof label === 'number' ? <Text style={styles.label}>{label}</Text> : label)}
|
||||
(typeof label === 'string' || typeof label === 'number' ? (
|
||||
<Text style={textFieldStyles.label}>{label}</Text>
|
||||
) : (
|
||||
label
|
||||
))}
|
||||
<TextInput
|
||||
ref={ref}
|
||||
style={[styles.input, focused && styles.inputFocused, fieldStyle, style]}
|
||||
style={[textFieldStyles.input, focused && textFieldStyles.inputFocused, fieldStyle, style]}
|
||||
placeholderTextColor={placeholderTextColor ?? PLACEHOLDER_COLOR}
|
||||
onFocus={(event) => {
|
||||
setFocused(true);
|
||||
@@ -51,7 +55,8 @@ const TextField = React.forwardRef<TextInputInstance, TextFieldProps>(
|
||||
|
||||
TextField.displayName = 'TextField';
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
// Shared with SelectField, so tappable fields look like typed ones
|
||||
export const textFieldStyles = StyleSheet.create({
|
||||
container: {
|
||||
marginVertical: 6,
|
||||
},
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
import Icon from '@react-native-vector-icons/feather/static';
|
||||
import React, { useState } from 'react';
|
||||
import { ActivityIndicator, FlatList, Modal, Pressable, StyleSheet, Text, View } from 'react-native';
|
||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||
|
||||
import Button from '@/components/Button';
|
||||
import SelectField from '@/components/SelectField';
|
||||
import TextField from '@/components/TextField';
|
||||
import { usePlacesOfStudy } from '@/queries/usePlacesOfStudy';
|
||||
import theme from '@/theme';
|
||||
import type { PlaceOfStudy } from '@/types/data';
|
||||
|
||||
type PlaceOfStudyFieldProps = {
|
||||
/** The chosen PlaceOfStudy id, or empty */
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
error?: string;
|
||||
};
|
||||
|
||||
const matches = (place: PlaceOfStudy, search: string) => {
|
||||
const needle = search.trim().toLocaleLowerCase('nb');
|
||||
return (
|
||||
place.name.toLocaleLowerCase('nb').includes(needle) || place.shortName.toLocaleLowerCase('nb').includes(needle)
|
||||
);
|
||||
};
|
||||
|
||||
/** Opens a searchable sheet with galtinn's places of study; short names (often lowercase, e.g. uio) only feed the search */
|
||||
const PlaceOfStudyField = ({ value, onChange, error }: PlaceOfStudyFieldProps) => {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [search, setSearch] = useState('');
|
||||
const { data: places, isPending, isError, refetch } = usePlacesOfStudy();
|
||||
const selected = places?.find((place) => place.id === value);
|
||||
const results = places?.filter((place) => matches(place, search)) ?? [];
|
||||
|
||||
const close = () => {
|
||||
setOpen(false);
|
||||
setSearch('');
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<SelectField
|
||||
label="Studiested"
|
||||
valueText={selected ? selected.name : undefined}
|
||||
placeholder="Velg studiested"
|
||||
icon="chevron-down"
|
||||
onPress={() => setOpen(true)}
|
||||
error={error}
|
||||
active={open}
|
||||
/>
|
||||
<Modal visible={open} animationType="slide" presentationStyle="pageSheet" onRequestClose={close}>
|
||||
<SafeAreaView style={styles.sheet} edges={['top', 'bottom']}>
|
||||
<View style={styles.header}>
|
||||
<Text style={styles.title}>Velg studiested</Text>
|
||||
<Pressable accessibilityRole="button" accessibilityLabel="Lukk" onPress={close} hitSlop={12}>
|
||||
<Icon name="x" size={24} color={theme.colors.chateauBlue} />
|
||||
</Pressable>
|
||||
</View>
|
||||
<TextField
|
||||
accessibilityLabel="Søk etter studiested"
|
||||
placeholder="Søk etter studiested"
|
||||
value={search}
|
||||
onChangeText={setSearch}
|
||||
autoCorrect={false}
|
||||
returnKeyType="search"
|
||||
/>
|
||||
{isPending && (
|
||||
<ActivityIndicator accessible accessibilityRole="progressbar" color={theme.colors.goldenOrange} />
|
||||
)}
|
||||
{isError && (
|
||||
<View style={styles.message}>
|
||||
<Text style={styles.messageText}>Kunne ikke hente studiesteder</Text>
|
||||
<Button small onPress={() => refetch()}>
|
||||
Prøv igjen
|
||||
</Button>
|
||||
</View>
|
||||
)}
|
||||
{places && (
|
||||
<FlatList
|
||||
data={results}
|
||||
keyExtractor={(place) => place.id}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
ListEmptyComponent={<Text style={[styles.messageText, styles.message]}>Fant ingen studiesteder</Text>}
|
||||
renderItem={({ item: place }) => (
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
accessibilityState={{ selected: place.id === value }}
|
||||
onPress={() => {
|
||||
onChange(place.id);
|
||||
close();
|
||||
}}
|
||||
style={styles.row}
|
||||
>
|
||||
<Text style={styles.rowText}>{place.name}</Text>
|
||||
{place.id === value && <Icon name="check" size={18} color={theme.colors.chateauBlue} />}
|
||||
</Pressable>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</SafeAreaView>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default PlaceOfStudyField;
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
sheet: {
|
||||
flex: 1,
|
||||
backgroundColor: theme.colors.betongGray,
|
||||
paddingHorizontal: 16,
|
||||
},
|
||||
header: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
paddingVertical: 16,
|
||||
},
|
||||
title: {
|
||||
...theme.text,
|
||||
...theme.typography.h3,
|
||||
},
|
||||
message: {
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
marginTop: 16,
|
||||
},
|
||||
messageText: {
|
||||
...theme.text,
|
||||
...theme.typography.body,
|
||||
textAlign: 'center',
|
||||
},
|
||||
// Divided like the event list
|
||||
row: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
paddingVertical: 14,
|
||||
paddingHorizontal: 4,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: theme.colors.neufPink,
|
||||
},
|
||||
rowText: {
|
||||
...theme.text,
|
||||
...theme.typography.body,
|
||||
flex: 1,
|
||||
},
|
||||
});
|
||||
@@ -1,11 +1,11 @@
|
||||
import { act, fireEvent, waitFor, type RenderResult } from '@testing-library/react-native';
|
||||
import { act, fireEvent, screen, waitFor, type RenderResult } from '@testing-library/react-native';
|
||||
import React from 'react';
|
||||
|
||||
import * as api from '@/api';
|
||||
import { ApiError } from '@/errors';
|
||||
import UserRegister from '@/modules/register/UserRegister';
|
||||
import { focusedFieldLabel, renderWithProviders, testUser } from '@/testUtils';
|
||||
import type { UserType } from '@/types/data';
|
||||
import type { PlaceOfStudy, UserType } from '@/types/data';
|
||||
|
||||
// The screen is pushed on top of Login and closes itself with goBack() once
|
||||
// registration succeeds; stub the navigation object so we can assert on that.
|
||||
@@ -15,6 +15,24 @@ jest.mock('@react-navigation/native', () => {
|
||||
return { ...actual, useNavigation: () => ({ goBack: mockGoBack }) };
|
||||
});
|
||||
|
||||
// The native picker can't render in Jest; stand in a button that picks a fixed date
|
||||
jest.mock('@expo/ui/community/datetime-picker', () => {
|
||||
const { Pressable, Text } = jest.requireActual('react-native');
|
||||
return {
|
||||
DateTimePicker: ({ onValueChange }: { onValueChange: (event: unknown, date: Date) => void }) => (
|
||||
<Pressable accessibilityRole="button" onPress={() => onValueChange({}, new Date(2001, 1, 3))}>
|
||||
<Text>Velg 3. februar 2001</Text>
|
||||
</Pressable>
|
||||
),
|
||||
};
|
||||
});
|
||||
|
||||
const placesOfStudy: PlaceOfStudy[] = [
|
||||
{ id: '3', name: 'Handelshøyskolen BI', shortName: 'bi' },
|
||||
{ id: '5', name: 'OsloMet', shortName: 'oslomet' },
|
||||
{ id: '7', name: 'Universitetet i Oslo', shortName: 'uio' },
|
||||
];
|
||||
|
||||
const validFormValues: Record<string, string> = {
|
||||
Fornavn: 'Grisen',
|
||||
Etternavn: 'Testesen',
|
||||
@@ -23,14 +41,22 @@ const validFormValues: Record<string, string> = {
|
||||
Passord: 'hunter2',
|
||||
};
|
||||
|
||||
const fillForm = async (getByLabelText: RenderResult['getByLabelText'], overrides: Record<string, string> = {}) => {
|
||||
const fillForm = async (
|
||||
{ getByLabelText, getByText, findByText }: Pick<RenderResult, 'getByLabelText' | 'getByText' | 'findByText'>,
|
||||
overrides: Record<string, string> = {},
|
||||
) => {
|
||||
for (const [label, value] of Object.entries({ ...validFormValues, ...overrides })) {
|
||||
await fireEvent.changeText(getByLabelText(label), value);
|
||||
}
|
||||
await fireEvent.press(getByLabelText('Fødselsdato'));
|
||||
await fireEvent.press(getByText('Velg 3. februar 2001'));
|
||||
await fireEvent.press(getByLabelText('Studiested'));
|
||||
await fireEvent.press(await findByText('Universitetet i Oslo'));
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
jest.spyOn(api, 'fetchPlacesOfStudy').mockResolvedValue(placesOfStudy);
|
||||
});
|
||||
|
||||
it('renders the registration form', async () => {
|
||||
@@ -57,9 +83,9 @@ it('submits the form, shows a spinner while pending and navigates back on succes
|
||||
const registerUser = jest
|
||||
.spyOn(api, 'registerUser')
|
||||
.mockImplementationOnce(() => new Promise((resolve) => (resolveRegister = resolve)));
|
||||
const { getByText, getByRole, getByLabelText } = await renderWithProviders(<UserRegister />);
|
||||
const { getByText, getByRole } = await renderWithProviders(<UserRegister />);
|
||||
|
||||
await fillForm(getByLabelText);
|
||||
await fillForm(screen);
|
||||
await waitFor(() => expect(getByRole('button', { name: 'Registrer meg' })).toBeEnabled());
|
||||
await fireEvent.press(getByText('Registrer meg'));
|
||||
|
||||
@@ -71,6 +97,8 @@ it('submits the form, shows a spinner while pending and navigates back on succes
|
||||
last_name: 'Testesen',
|
||||
email: 'grisen@example.test',
|
||||
phone_number: '+4712345678',
|
||||
date_of_birth: '2001-02-03',
|
||||
place_of_study: '7',
|
||||
password: 'hunter2',
|
||||
}),
|
||||
);
|
||||
@@ -87,9 +115,9 @@ it('shows server field errors on the matching form field', async () => {
|
||||
jest
|
||||
.spyOn(api, 'registerUser')
|
||||
.mockRejectedValueOnce(new ApiError({ email: ['Denne e-postadressen er allerede i bruk'] }, 400));
|
||||
const { getByText, getByRole, getByLabelText, findByText } = await renderWithProviders(<UserRegister />);
|
||||
const { getByText, getByRole, findByText } = await renderWithProviders(<UserRegister />);
|
||||
|
||||
await fillForm(getByLabelText);
|
||||
await fillForm(screen);
|
||||
await waitFor(() => expect(getByRole('button', { name: 'Registrer meg' })).toBeEnabled());
|
||||
await fireEvent.press(getByText('Registrer meg'));
|
||||
|
||||
@@ -110,9 +138,9 @@ it('moves focus down the form when a field is submitted from the keyboard', asyn
|
||||
|
||||
it('shows a generic error when registration fails on the network', async () => {
|
||||
jest.spyOn(api, 'registerUser').mockRejectedValueOnce(new Error('network down'));
|
||||
const { getByText, getByRole, getByLabelText, findByText } = await renderWithProviders(<UserRegister />);
|
||||
const { getByText, getByRole, findByText } = await renderWithProviders(<UserRegister />);
|
||||
|
||||
await fillForm(getByLabelText);
|
||||
await fillForm(screen);
|
||||
await waitFor(() => expect(getByRole('button', { name: 'Registrer meg' })).toBeEnabled());
|
||||
await fireEvent.press(getByText('Registrer meg'));
|
||||
|
||||
@@ -124,10 +152,68 @@ it('rejects an invalid e-mail address without submitting', async () => {
|
||||
const registerUser = jest.spyOn(api, 'registerUser');
|
||||
const { getByLabelText, findByText } = await renderWithProviders(<UserRegister />);
|
||||
|
||||
await fillForm(getByLabelText, { 'E-post': 'not-an-email' });
|
||||
await fillForm(screen, { 'E-post': 'not-an-email' });
|
||||
// Enter on the last field submits too, bypassing the disabled button
|
||||
await fireEvent(getByLabelText('Passord'), 'submitEditing');
|
||||
|
||||
expect(await findByText('er ikke en gyldig e-post')).toBeTruthy();
|
||||
expect(registerUser).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('keeps the submit button disabled until a birth date and place of study are chosen', async () => {
|
||||
const { getByLabelText, getByRole, getByText, findByText } = await renderWithProviders(<UserRegister />);
|
||||
|
||||
for (const [label, value] of Object.entries(validFormValues)) {
|
||||
await fireEvent.changeText(getByLabelText(label), value);
|
||||
}
|
||||
expect(getByRole('button', { name: 'Registrer meg' })).toBeDisabled();
|
||||
|
||||
await fireEvent.press(getByLabelText('Fødselsdato'));
|
||||
await fireEvent.press(getByText('Velg 3. februar 2001'));
|
||||
expect(getByLabelText('Fødselsdato')).toHaveAccessibilityValue({ text: '03.02.2001' });
|
||||
expect(getByRole('button', { name: 'Registrer meg' })).toBeDisabled();
|
||||
|
||||
await fireEvent.press(getByLabelText('Studiested'));
|
||||
await fireEvent.press(await findByText('Universitetet i Oslo'));
|
||||
expect(getByLabelText('Studiested')).toHaveAccessibilityValue({ text: 'Universitetet i Oslo' });
|
||||
await waitFor(() => expect(getByRole('button', { name: 'Registrer meg' })).toBeEnabled());
|
||||
});
|
||||
|
||||
it('filters places of study by name or short name, showing only the name', async () => {
|
||||
const { getByLabelText, findByText, queryByText } = await renderWithProviders(<UserRegister />);
|
||||
|
||||
await fireEvent.press(getByLabelText('Studiested'));
|
||||
expect(await findByText('Handelshøyskolen BI')).toBeTruthy();
|
||||
|
||||
await fireEvent.changeText(getByLabelText('Søk etter studiested'), 'uio');
|
||||
expect(queryByText('Handelshøyskolen BI')).toBeNull();
|
||||
expect(await findByText('Universitetet i Oslo')).toBeTruthy();
|
||||
|
||||
await fireEvent.changeText(getByLabelText('Søk etter studiested'), 'NTNU');
|
||||
expect(await findByText('Fant ingen studiesteder')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('lets the user retry when the places of study fail to load', async () => {
|
||||
jest
|
||||
.spyOn(api, 'fetchPlacesOfStudy')
|
||||
.mockRejectedValueOnce(new Error('network down'))
|
||||
.mockResolvedValue(placesOfStudy);
|
||||
const { getByLabelText, getByText, findByText } = await renderWithProviders(<UserRegister />);
|
||||
|
||||
await fireEvent.press(getByLabelText('Studiested'));
|
||||
expect(await findByText('Kunne ikke hente studiesteder')).toBeTruthy();
|
||||
|
||||
await fireEvent.press(getByText('Prøv igjen'));
|
||||
expect(await findByText('Universitetet i Oslo')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('shows a server error for the place of study under its field', async () => {
|
||||
jest.spyOn(api, 'registerUser').mockRejectedValueOnce(new ApiError({ place_of_study: ['Ugyldig studiested'] }, 400));
|
||||
const { getByText, getByRole, findByText } = await renderWithProviders(<UserRegister />);
|
||||
|
||||
await fillForm(screen);
|
||||
await waitFor(() => expect(getByRole('button', { name: 'Registrer meg' })).toBeEnabled());
|
||||
await fireEvent.press(getByText('Registrer meg'));
|
||||
|
||||
expect(await findByText('Ugyldig studiested')).toBeTruthy();
|
||||
});
|
||||
@@ -1,15 +1,24 @@
|
||||
import { subYears } from 'date-fns';
|
||||
import * as EmailValidator from 'email-validator';
|
||||
import React from 'react';
|
||||
import { Controller } from 'react-hook-form';
|
||||
import { ActivityIndicator, StyleSheet, ScrollView } from 'react-native';
|
||||
|
||||
import Button from '@/components/Button';
|
||||
import DateField from '@/components/DateField';
|
||||
import FormErrors from '@/components/FormErrors';
|
||||
import FormInput from '@/components/FormInput';
|
||||
import PrivacyButton from '@/components/PrivacyButton';
|
||||
import theme from '@/theme';
|
||||
|
||||
import PlaceOfStudyField from './PlaceOfStudyField';
|
||||
import useUserRegister from './useUserRegister';
|
||||
|
||||
// Start the birth date wheel around a typical student's age
|
||||
const TODAY = new Date();
|
||||
const INITIAL_BIRTH_DATE = new Date(TODAY.getFullYear() - 20, 0, 1);
|
||||
const EARLIEST_BIRTH_DATE = subYears(TODAY, 120);
|
||||
|
||||
const UserRegister = () => {
|
||||
const { isRegisteringUser, onSubmit, isValid, isSubmitted, control, errors, registerError, setFocus } =
|
||||
useUserRegister();
|
||||
@@ -55,6 +64,32 @@ const UserRegister = () => {
|
||||
keyboardType="phone-pad"
|
||||
onSubmitEditing={() => setFocus('password')}
|
||||
/>
|
||||
<Controller
|
||||
name="date_of_birth"
|
||||
control={control}
|
||||
defaultValue=""
|
||||
rules={{ required: 'Kan ikke være tomt!' }}
|
||||
render={({ field: { value, onChange } }) => (
|
||||
<DateField
|
||||
label="Fødselsdato"
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
initialPickerDate={INITIAL_BIRTH_DATE}
|
||||
minimumDate={EARLIEST_BIRTH_DATE}
|
||||
maximumDate={TODAY}
|
||||
error={errors.date_of_birth?.message}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
name="place_of_study"
|
||||
control={control}
|
||||
defaultValue=""
|
||||
rules={{ required: 'Kan ikke være tomt!' }}
|
||||
render={({ field: { value, onChange } }) => (
|
||||
<PlaceOfStudyField value={value} onChange={onChange} error={errors.place_of_study?.message} />
|
||||
)}
|
||||
/>
|
||||
<FormInput
|
||||
name="password"
|
||||
label="Passord"
|
||||
|
||||
@@ -2,4 +2,5 @@ export const queryKeys = {
|
||||
user: ['user'],
|
||||
events: ['events'],
|
||||
membershipTypes: ['membershipTypes'],
|
||||
placesOfStudy: ['placesOfStudy'],
|
||||
} as const;
|
||||
@@ -0,0 +1,36 @@
|
||||
import { arrangePlacesOfStudy } from '@/queries/usePlacesOfStudy';
|
||||
|
||||
const place = (id: string, name: string, shortName = '') => ({ id, name, shortName });
|
||||
|
||||
it('pins "Ikke student" first and "Annet" last and keeps the schools in order', () => {
|
||||
// As galtinn returns them: sorted by name
|
||||
const places = [
|
||||
place('1', 'Annet'),
|
||||
place('2', 'Handelshøyskolen BI', 'bi'),
|
||||
place('4', 'Ikke student'),
|
||||
place('5', 'OsloMet', 'oslomet'),
|
||||
place('6', 'Universitetet i Oslo', 'uio'),
|
||||
];
|
||||
|
||||
expect(arrangePlacesOfStudy(places).map(({ name }) => name)).toEqual([
|
||||
'Ikke student',
|
||||
'Handelshøyskolen BI',
|
||||
'OsloMet',
|
||||
'Universitetet i Oslo',
|
||||
'Annet',
|
||||
]);
|
||||
});
|
||||
|
||||
it('matches the catch-all entries regardless of case and surrounding spaces', () => {
|
||||
const places = [place('1', ' annet'), place('3', 'ikke Student'), place('4', 'UiO')];
|
||||
|
||||
expect(arrangePlacesOfStudy(places).map(({ id }) => id)).toEqual(['3', '4', '1']);
|
||||
});
|
||||
|
||||
it('does not reorder the list it was given', () => {
|
||||
const places = [place('1', 'Annet'), place('2', 'Ikke student')];
|
||||
|
||||
arrangePlacesOfStudy(places);
|
||||
|
||||
expect(places.map(({ id }) => id)).toEqual(['1', '2']);
|
||||
});
|
||||
@@ -0,0 +1,28 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
|
||||
import { fetchPlacesOfStudy } from '@/api';
|
||||
import { queryKeys } from '@/queries/queryKeys';
|
||||
import type { PlaceOfStudy } from '@/types/data';
|
||||
|
||||
// Galtinn's catch-all entries, matched by name and pinned to each end of the list
|
||||
const FIRST = 'ikke student';
|
||||
const LAST = 'annet';
|
||||
|
||||
const key = (place: PlaceOfStudy) => place.name.trim().toLocaleLowerCase('nb');
|
||||
|
||||
/** "Ikke student" first and "Annet" last around the schools in galtinn's name order */
|
||||
export function arrangePlacesOfStudy(places: PlaceOfStudy[]): PlaceOfStudy[] {
|
||||
const rank = (place: PlaceOfStudy) => (key(place) === FIRST ? -1 : key(place) === LAST ? 1 : 0);
|
||||
// Copied so the cached list is left alone; Array.prototype.sort is stable, so the schools keep their order
|
||||
return [...places].sort((a, b) => rank(a) - rank(b));
|
||||
}
|
||||
|
||||
/** The schools to choose from when registering. */
|
||||
export function usePlacesOfStudy() {
|
||||
return useQuery({
|
||||
queryKey: queryKeys.placesOfStudy,
|
||||
queryFn: fetchPlacesOfStudy,
|
||||
select: arrangePlacesOfStudy,
|
||||
staleTime: 60 * 60 * 1000,
|
||||
});
|
||||
}
|
||||
@@ -27,6 +27,13 @@ export interface MembershipType {
|
||||
end_date: string;
|
||||
}
|
||||
|
||||
/** A school to pick when registering; short_name is often an abbreviation such as UiO, or empty. */
|
||||
export interface PlaceOfStudy {
|
||||
id: string;
|
||||
name: string;
|
||||
shortName: string;
|
||||
}
|
||||
|
||||
/** A purchasable membership type; price is in øre. */
|
||||
export interface MembershipTypeOption {
|
||||
slug: string;
|
||||
|
||||
@@ -3,5 +3,9 @@ export interface UserRegisterFormValues {
|
||||
last_name: string;
|
||||
email: string;
|
||||
phone_number: string;
|
||||
/** yyyy-MM-dd, as the API expects */
|
||||
date_of_birth: string;
|
||||
/** The PlaceOfStudy id from the placesOfStudy query */
|
||||
place_of_study: string;
|
||||
password: string;
|
||||
}
|
||||
Reference in new issue
Block a user