diff --git a/package-lock.json b/package-lock.json
index 823e413..6c54c93 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -31,7 +31,7 @@
"expo-splash-screen": "~57.0.8",
"expo-status-bar": "~57.0.1",
"react": "19.2.3",
- "react-hook-form": "^7.52.1",
+ "react-hook-form": "^7.87.0",
"react-native": "0.86.3",
"react-native-fast-confetti": "^2.0.2",
"react-native-gesture-handler": "~2.32.0",
@@ -13663,9 +13663,9 @@
}
},
"node_modules/react-hook-form": {
- "version": "7.83.0",
- "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.83.0.tgz",
- "integrity": "sha512-AXt8cMCmx5a7u4uvpb2uRFVrWQhllI4pV+LSykxIac/hjt44TnQkmX9BKuQi2i+LDC62esmiLpilkav+kjVf/A==",
+ "version": "7.87.0",
+ "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.87.0.tgz",
+ "integrity": "sha512-zhFzWvLxNHH+8839OnZcUxgMZw88ah2jZWDWvKWgF3Tpbnd0vKL+dlcuU3nZVWESZQjd81EW8K+wU+cYfYAc0w==",
"license": "MIT",
"engines": {
"node": ">=18.0.0"
diff --git a/package.json b/package.json
index 0e2c571..cfb8a19 100644
--- a/package.json
+++ b/package.json
@@ -38,7 +38,7 @@
"expo-splash-screen": "~57.0.8",
"expo-status-bar": "~57.0.1",
"react": "19.2.3",
- "react-hook-form": "^7.52.1",
+ "react-hook-form": "^7.87.0",
"react-native": "0.86.3",
"react-native-fast-confetti": "^2.0.2",
"react-native-gesture-handler": "~2.32.0",
diff --git a/src/modules/login/Login.test.tsx b/src/modules/login/Login.test.tsx
index 7a579e5..d308970 100644
--- a/src/modules/login/Login.test.tsx
+++ b/src/modules/login/Login.test.tsx
@@ -1,10 +1,11 @@
import { fireEvent, waitFor, type RenderResult } from '@testing-library/react-native';
import React from 'react';
+import { Linking } from 'react-native';
import * as api from '@/api';
import { ApiError } from '@/errors';
import Login from '@/modules/login/Login';
-import { renderWithProviders } from '@/testUtils';
+import { focusedFieldLabel, renderWithProviders } from '@/testUtils';
// Login is pushed on top of Membership and closes itself with goBack() once
// authentication succeeds; stub the navigation object so we can assert on that.
@@ -35,6 +36,24 @@ it('renders the login form', () => {
expect(getByText('Glemt passord?')).toBeTruthy();
});
+it('moves focus to the password field when the username is submitted from the keyboard', async () => {
+ const { getByLabelText } = renderWithProviders();
+
+ fireEvent(getByLabelText('E-post / Brukernavn'), 'submitEditing');
+
+ // react-hook-form's setFocus defers the focus call to the next tick
+ await waitFor(() => expect(focusedFieldLabel()).toBe('Passord'));
+});
+
+it('opens the forgot password page in the browser', () => {
+ const { getByText } = renderWithProviders();
+
+ fireEvent.press(getByText('Glemt passord?'));
+
+ // Not one of our own domains in the test env, so no UTM tagging (covered in utils.test.ts)
+ expect(Linking.openURL).toHaveBeenCalledWith('https://dusken.example.test/auth/password_reset/');
+});
+
it('logs in against the API and returns to the previous screen', async () => {
const login = jest.spyOn(api, 'login').mockResolvedValue({ token: 'token-123' });
const { getByText, getByLabelText } = renderWithProviders();
diff --git a/src/modules/register/UserRegister.test.tsx b/src/modules/register/UserRegister.test.tsx
index 05c71ea..b286ab7 100644
--- a/src/modules/register/UserRegister.test.tsx
+++ b/src/modules/register/UserRegister.test.tsx
@@ -5,7 +5,7 @@ import { ActivityIndicator } from 'react-native';
import * as api from '@/api';
import { ApiError } from '@/errors';
import UserRegister from '@/modules/register/UserRegister';
-import { renderWithProviders, testUser } from '@/testUtils';
+import { focusedFieldLabel, renderWithProviders, testUser } from '@/testUtils';
import type { UserType } from '@/types/data';
// The screen is pushed on top of Login and closes itself with goBack() once
@@ -34,6 +34,10 @@ beforeEach(() => {
jest.clearAllMocks();
});
+afterEach(() => {
+ jest.useRealTimers();
+});
+
it('renders the registration form', () => {
const { getByText } = renderWithProviders();
@@ -98,6 +102,47 @@ it('shows server field errors on the matching form field', async () => {
expect(mockGoBack).not.toHaveBeenCalled();
});
+it('moves focus down the form when a field is submitted from the keyboard', async () => {
+ const { getByLabelText } = renderWithProviders();
+ const labels = ['Fornavn', 'Etternavn', 'E-post', 'Mobilnummer', 'Passord'];
+
+ for (const [i, label] of labels.slice(0, -1).entries()) {
+ fireEvent(getByLabelText(label), 'submitEditing');
+ // react-hook-form's setFocus defers the focus call to the next tick
+ await waitFor(() => expect(focusedFieldLabel()).toBe(labels[i + 1]));
+ }
+});
+
+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 } = renderWithProviders();
+
+ fillForm(getByLabelText);
+ await waitFor(() => expect(getByRole('button', { name: 'Registrer meg' })).toBeEnabled());
+ fireEvent.press(getByText('Registrer meg'));
+
+ expect(await findByText('Kunne ikke sende inn skjemaet! Prøv igjen senere...')).toBeTruthy();
+ expect(mockGoBack).not.toHaveBeenCalled();
+});
+
+it('warns instead of submitting when the keyboard submits before validation has caught up', async () => {
+ jest.useFakeTimers();
+ const registerUser = jest.spyOn(api, 'registerUser');
+ const { getByLabelText, findByText, queryByText } = renderWithProviders();
+
+ // Filling in and submitting in the same tick reaches the submit handler while isValid still reflects the empty form
+ fillForm(getByLabelText);
+ fireEvent(getByLabelText('Passord'), 'submitEditing');
+
+ const message = 'Noen av feltene er ikke fylt ut riktig';
+ expect(await findByText(message)).toBeTruthy();
+ expect(registerUser).not.toHaveBeenCalled();
+
+ // The toast dismisses itself after five seconds
+ act(() => jest.advanceTimersByTime(5000));
+ expect(queryByText(message)).toBeNull();
+});
+
it('rejects an invalid e-mail address without submitting', async () => {
const registerUser = jest.spyOn(api, 'registerUser');
const { getByLabelText, findByText } = renderWithProviders();
diff --git a/src/testUtils.tsx b/src/testUtils.tsx
index 74dcb89..21d3c3c 100644
--- a/src/testUtils.tsx
+++ b/src/testUtils.tsx
@@ -3,6 +3,7 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { render } from '@testing-library/react-native';
import React from 'react';
import type { ReactElement } from 'react';
+import { TextInput } from 'react-native';
import { AuthProvider } from '@/auth/AuthContext';
import { queryKeys } from '@/queries/queryKeys';
@@ -29,6 +30,10 @@ export const testMembershipTypes: MembershipTypeOption[] = [
{ slug: 'support', price: 50000 },
];
+/** Label of the field focused last; the mocked TextInput shares a single focus() mock across instances. */
+export const focusedFieldLabel = () =>
+ (TextInput.prototype.focus as jest.Mock).mock.contexts.at(-1)?.props.accessibilityLabel;
+
interface RenderWithProvidersOptions {
/** Auth token the app has stored; being set means the user is logged in. */
initialToken?: string | null;