From ee4c1e4cef63cdd90d57b8b7de8247fe4a88d154 Mon Sep 17 00:00:00 2001 From: Jonas Braathen Date: Thu, 29 Oct 2026 22:52:51 +0100 Subject: [PATCH] feat(register): ask for date of birth and place of study --- TODO.md | 6 +- package-lock.json | 29 ++++ package.json | 1 + src/api.test.ts | 33 ++++- src/api.ts | 28 +++- src/components/DateField.tsx | 86 ++++++++++++ src/components/FormInput.tsx | 7 +- src/components/SelectField.tsx | 64 +++++++++ src/components/TextField.tsx | 13 +- src/modules/register/PlaceOfStudyField.tsx | 149 +++++++++++++++++++++ src/modules/register/UserRegister.test.tsx | 106 +++++++++++++-- src/modules/register/UserRegister.tsx | 35 +++++ src/queries/queryKeys.ts | 1 + src/queries/usePlacesOfStudy.test.ts | 36 +++++ src/queries/usePlacesOfStudy.ts | 28 ++++ src/types/data.ts | 7 + src/types/forms.ts | 4 + 17 files changed, 609 insertions(+), 24 deletions(-) create mode 100644 src/components/DateField.tsx create mode 100644 src/components/SelectField.tsx create mode 100644 src/modules/register/PlaceOfStudyField.tsx create mode 100644 src/queries/usePlacesOfStudy.test.ts create mode 100644 src/queries/usePlacesOfStudy.ts diff --git a/TODO.md b/TODO.md index d111c0d..2156a28 100644 --- a/TODO.md +++ b/TODO.md @@ -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, diff --git a/package-lock.json b/package-lock.json index 85262f9..7091e7f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 688152c..58d91ad 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/api.test.ts b/src/api.test.ts index 1f3718b..7320c28 100644 --- a/src/api.test.ts +++ b/src/api.test.ts @@ -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 }]; diff --git a/src/api.ts b/src/api.ts index 86fafe9..ab6c117 100644 --- a/src/api.ts +++ b/src/api.ts @@ -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 { return membershipTypes; } +export async function fetchPlacesOfStudy(): Promise { + 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 { const response = await fetchWithTimeout( diff --git a/src/components/DateField.tsx b/src/components/DateField.tsx new file mode 100644 index 0000000..7630311 --- /dev/null +++ b/src/components/DateField.tsx @@ -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 ( + <> + { + // 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 + { + setOpen(false); + choose(chosen); + }} + onDismiss={() => setOpen(false)} + /> + ) : ( + choose(chosen)} + /> + ))} + + ); +}; + +export default DateField; diff --git a/src/components/FormInput.tsx b/src/components/FormInput.tsx index 3459d7d..32885ad 100644 --- a/src/components/FormInput.tsx +++ b/src/components/FormInput.tsx @@ -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 && {fieldError.message?.toString()}} + {fieldError && {fieldError.message?.toString()}} )} 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, }, diff --git a/src/components/SelectField.tsx b/src/components/SelectField.tsx new file mode 100644 index 0000000..d0d86e6 --- /dev/null +++ b/src/components/SelectField.tsx @@ -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['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) => ( + + {label} + + + {valueText || placeholder} + + + + {error && {error}} + +); + +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`, + }, +}); diff --git a/src/components/TextField.tsx b/src/components/TextField.tsx index 9a95a1f..be93807 100644 --- a/src/components/TextField.tsx +++ b/src/components/TextField.tsx @@ -27,12 +27,16 @@ const TextField = React.forwardRef( const [focused, setFocused] = useState(false); return ( - + {label != null && - (typeof label === 'string' || typeof label === 'number' ? {label} : label)} + (typeof label === 'string' || typeof label === 'number' ? ( + {label} + ) : ( + label + ))} { setFocused(true); @@ -51,7 +55,8 @@ const TextField = React.forwardRef( 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, }, diff --git a/src/modules/register/PlaceOfStudyField.tsx b/src/modules/register/PlaceOfStudyField.tsx new file mode 100644 index 0000000..b607ce0 --- /dev/null +++ b/src/modules/register/PlaceOfStudyField.tsx @@ -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 ( + <> + setOpen(true)} + error={error} + active={open} + /> + + + + Velg studiested + + + + + + {isPending && ( + + )} + {isError && ( + + Kunne ikke hente studiesteder + + + )} + {places && ( + place.id} + keyboardShouldPersistTaps="handled" + ListEmptyComponent={Fant ingen studiesteder} + renderItem={({ item: place }) => ( + { + onChange(place.id); + close(); + }} + style={styles.row} + > + {place.name} + {place.id === value && } + + )} + /> + )} + + + + ); +}; + +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, + }, +}); diff --git a/src/modules/register/UserRegister.test.tsx b/src/modules/register/UserRegister.test.tsx index e13265f..b3208cc 100644 --- a/src/modules/register/UserRegister.test.tsx +++ b/src/modules/register/UserRegister.test.tsx @@ -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 }) => ( + onValueChange({}, new Date(2001, 1, 3))}> + Velg 3. februar 2001 + + ), + }; +}); + +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 = { Fornavn: 'Grisen', Etternavn: 'Testesen', @@ -23,14 +41,22 @@ const validFormValues: Record = { Passord: 'hunter2', }; -const fillForm = async (getByLabelText: RenderResult['getByLabelText'], overrides: Record = {}) => { +const fillForm = async ( + { getByLabelText, getByText, findByText }: Pick, + overrides: Record = {}, +) => { 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(); + const { getByText, getByRole } = await renderWithProviders(); - 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(); + const { getByText, getByRole, findByText } = await renderWithProviders(); - 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(); + const { getByText, getByRole, findByText } = await renderWithProviders(); - 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(); - 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(); + + 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(); + + 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(); + + 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(); + + await fillForm(screen); + await waitFor(() => expect(getByRole('button', { name: 'Registrer meg' })).toBeEnabled()); + await fireEvent.press(getByText('Registrer meg')); + + expect(await findByText('Ugyldig studiested')).toBeTruthy(); +}); diff --git a/src/modules/register/UserRegister.tsx b/src/modules/register/UserRegister.tsx index 1712dd4..d8db08a 100644 --- a/src/modules/register/UserRegister.tsx +++ b/src/modules/register/UserRegister.tsx @@ -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')} /> + ( + + )} + /> + ( + + )} + /> ({ 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']); +}); diff --git a/src/queries/usePlacesOfStudy.ts b/src/queries/usePlacesOfStudy.ts new file mode 100644 index 0000000..31733f6 --- /dev/null +++ b/src/queries/usePlacesOfStudy.ts @@ -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, + }); +} diff --git a/src/types/data.ts b/src/types/data.ts index ec1b964..ec07fd7 100644 --- a/src/types/data.ts +++ b/src/types/data.ts @@ -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; diff --git a/src/types/forms.ts b/src/types/forms.ts index aba51aa..d049e63 100644 --- a/src/types/forms.ts +++ b/src/types/forms.ts @@ -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; }