feat(register): ask for date of birth and place of study

This commit is contained in:
ponas committed 2026-10-29 22:52:51 +01:00
1 parent c957cff31b
commit ee4c1e4cef
17 files changed
+609 -24

No files matched your search

+2 -4
View File
@@ -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,
+29
View File
@@ -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",
+1
View File
@@ -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
View File
@@ -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
View File
@@ -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(
+86
View File
@@ -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;
+4 -3
View File
@@ -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,
},
+64
View File
@@ -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`,
},
});
+9 -4
View File
@@ -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,
},
+149
View File
@@ -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,
},
});
+96 -10
View File
@@ -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();
});
+35
View File
@@ -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"
+1
View File
@@ -2,4 +2,5 @@ export const queryKeys = {
user: ['user'],
events: ['events'],
membershipTypes: ['membershipTypes'],
placesOfStudy: ['placesOfStudy'],
} as const;
+36
View File
@@ -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']);
});
+28
View File
@@ -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,
});
}
+7
View File
@@ -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;
+4
View File
@@ -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;
}