From 14f5d9d7b75389ede069b4506ef54fa90020cdfe Mon Sep 17 00:00:00 2001 From: Lukas Prochazka Date: Mon, 4 Nov 2024 15:39:58 +0100 Subject: [PATCH 1/7] Refactor profile and guest pages to use useRouter --- app/(tabs)/profile.tsx | 9 +++++---- app/guests/[guestId].tsx | 20 ++++++++++++++++++-- 2 files changed, 23 insertions(+), 6 deletions(-) diff --git a/app/(tabs)/profile.tsx b/app/(tabs)/profile.tsx index 40b4215..13abc73 100644 --- a/app/(tabs)/profile.tsx +++ b/app/(tabs)/profile.tsx @@ -1,4 +1,4 @@ -import { router, useFocusEffect } from 'expo-router'; +import { useFocusEffect, useRouter } from 'expo-router'; import React, { useCallback } from 'react'; import { Alert, Pressable, StyleSheet, Text, View } from 'react-native'; import type { LogoutResponseBodyGet } from '../(auth)/api/logout+api'; @@ -33,6 +33,8 @@ const styles = StyleSheet.create({ }); export default function Profile() { + const router = useRouter(); + useFocusEffect( useCallback(() => { async function getUser() { @@ -41,14 +43,13 @@ export default function Profile() { const body: UserResponseBodyGet = await response.json(); if ('error' in body) { - Alert.alert('Error', body.error, [{ text: 'OK' }]); - return router.push('/(auth)/login'); + router.replace('/(auth)/login?returnTo=/(tabs)/profile'); } } getUser().catch((error) => { console.error(error); }); - }, []), + }, [router]), ); return ( diff --git a/app/guests/[guestId].tsx b/app/guests/[guestId].tsx index 629eeb0..65034f6 100644 --- a/app/guests/[guestId].tsx +++ b/app/guests/[guestId].tsx @@ -1,6 +1,6 @@ import { Ionicons } from '@expo/vector-icons'; import { Image } from 'expo-image'; -import { router, useFocusEffect, useLocalSearchParams } from 'expo-router'; +import { useFocusEffect, useLocalSearchParams, useRouter } from 'expo-router'; import { useCallback, useState } from 'react'; import { Pressable, @@ -13,6 +13,7 @@ import { import placeholder from '../../assets/candidate-default.avif'; import { colors } from '../../constants/colors'; import type { GuestResponseBodyGet } from '../api/guests/[guestId]+api'; +import type { UserResponseBodyGet } from '../api/user+api'; const styles = StyleSheet.create({ container: { @@ -115,11 +116,26 @@ export default function GuestPage() { const [attending, setAttending] = useState(false); const [focusedInput, setFocusedInput] = useState(); + const router = useRouter(); + // Dynamic import of images // const imageContext = require.context('../../assets', false, /\.(avif)$/); useFocusEffect( useCallback(() => { + async function getUser() { + const response = await fetch('/api/user'); + + const body: UserResponseBodyGet = await response.json(); + + if ('error' in body) { + router.replace(`/(auth)/login?returnTo=/(tabs)/guests`); + } + } + getUser().catch((error) => { + console.error(error); + }); + async function loadGuest() { if (typeof guestId !== 'string') { return; @@ -138,7 +154,7 @@ export default function GuestPage() { loadGuest().catch((error) => { console.error(error); }); - }, [guestId]), + }, [guestId, router]), ); if (typeof guestId !== 'string') { From fe44733efcfa7310df6aa739385487f2e13fb4dd Mon Sep 17 00:00:00 2001 From: Lukas Prochazka Date: Mon, 4 Nov 2024 15:49:14 +0100 Subject: [PATCH 2/7] Change to promise all --- app/(tabs)/guests.tsx | 34 ++++++++++++++++++---------------- 1 file changed, 18 insertions(+), 16 deletions(-) diff --git a/app/(tabs)/guests.tsx b/app/(tabs)/guests.tsx index 318ab68..57223b7 100644 --- a/app/(tabs)/guests.tsx +++ b/app/(tabs)/guests.tsx @@ -40,32 +40,34 @@ export default function Guests() { useCallback(() => { if (!isStale) return; - async function getUser() { - const response = await fetch('/api/user'); + async function getUserAndGuests() { + const [userResponse, guestsResponse]: [ + UserResponseBodyGet, + GuestsResponseBodyGet, + ] = await Promise.all([ + fetch('/api/user').then((response) => response.json()), + fetch('/api/guests').then((response) => response.json()), + ]); - const body: UserResponseBodyGet = await response.json(); + setIsStale(false); - if ('error' in body) { + if ('error' in userResponse) { router.replace('/(auth)/login?returnTo=/(tabs)/guests'); + return; } - } - async function getGuests() { - const response = await fetch('/api/guests'); - const body: GuestsResponseBodyGet = await response.json(); + if ('error' in guestsResponse) { + setGuests([]); + return; + } - setGuests(body.guests); - setIsStale(false); + setGuests(guestsResponse.guests); } - getUser().catch((error) => { - console.error(error); - }); - - getGuests().catch((error) => { + getUserAndGuests().catch((error) => { console.error(error); }); - }, [router, isStale]), + }, [isStale, router]), ); if (!fontsLoaded) { From d63f8f0cfbab156415fa08b71336101785bde185 Mon Sep 17 00:00:00 2001 From: Lukas Prochazka Date: Mon, 4 Nov 2024 16:32:06 +0100 Subject: [PATCH 3/7] Update app/(tabs)/profile.tsx Co-authored-by: Karl Horky --- app/(tabs)/profile.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/app/(tabs)/profile.tsx b/app/(tabs)/profile.tsx index 13abc73..6f5d563 100644 --- a/app/(tabs)/profile.tsx +++ b/app/(tabs)/profile.tsx @@ -44,6 +44,7 @@ export default function Profile() { if ('error' in body) { router.replace('/(auth)/login?returnTo=/(tabs)/profile'); + return; } } getUser().catch((error) => { From 240a21ecec9d090bce65355c5b81f5b0f6c175db Mon Sep 17 00:00:00 2001 From: Lukas Prochazka Date: Mon, 4 Nov 2024 16:52:54 +0100 Subject: [PATCH 4/7] Add promise all to guestId and noteId --- app/guests/[guestId].tsx | 37 ++++++++++++++++--------------------- app/notes/[noteId].tsx | 33 +++++++++++++++++++++++++-------- 2 files changed, 41 insertions(+), 29 deletions(-) diff --git a/app/guests/[guestId].tsx b/app/guests/[guestId].tsx index 65034f6..f4f8e4c 100644 --- a/app/guests/[guestId].tsx +++ b/app/guests/[guestId].tsx @@ -123,35 +123,30 @@ export default function GuestPage() { useFocusEffect( useCallback(() => { - async function getUser() { - const response = await fetch('/api/user'); - - const body: UserResponseBodyGet = await response.json(); - - if ('error' in body) { - router.replace(`/(auth)/login?returnTo=/(tabs)/guests`); - } - } - getUser().catch((error) => { - console.error(error); - }); - - async function loadGuest() { + async function getUserAndLoadGuest() { if (typeof guestId !== 'string') { return; } + const [userResponse, guestResponse]: [ + UserResponseBodyGet, + GuestResponseBodyGet, + ] = await Promise.all([ + fetch('/api/user').then((response) => response.json()), + fetch(`/api/guests/${guestId}`).then((response) => response.json()), + ]); - const response = await fetch(`/api/guests/${guestId}`); - const responseBody: GuestResponseBodyGet = await response.json(); + if ('error' in userResponse) { + router.replace(`/(auth)/login?returnTo=/guests/${guestId}`); + } - if ('guest' in responseBody) { - setFirstName(responseBody.guest.firstName); - setLastName(responseBody.guest.lastName); - setAttending(responseBody.guest.attending); + if ('guest' in guestResponse) { + setFirstName(guestResponse.guest.firstName); + setLastName(guestResponse.guest.lastName); + setAttending(guestResponse.guest.attending); } } - loadGuest().catch((error) => { + getUserAndLoadGuest().catch((error) => { console.error(error); }); }, [guestId, router]), diff --git a/app/notes/[noteId].tsx b/app/notes/[noteId].tsx index 7a72805..4a6cec8 100644 --- a/app/notes/[noteId].tsx +++ b/app/notes/[noteId].tsx @@ -1,9 +1,15 @@ -import { Link, useFocusEffect, useLocalSearchParams } from 'expo-router'; +import { + Link, + useFocusEffect, + useLocalSearchParams, + useRouter, +} from 'expo-router'; import { useCallback, useState } from 'react'; import { StyleSheet, Text, View } from 'react-native'; import { colors } from '../../constants/colors'; import type { Note as NoteType } from '../../migrations/00003-createTableNotes'; import type { NoteResponseBodyGet } from '../api/notes/[noteId]+api'; +import type { UserResponseBodyGet } from '../api/user+api'; const styles = StyleSheet.create({ container: { @@ -36,25 +42,36 @@ export default function Note() { const { noteId } = useLocalSearchParams(); const [note, setNote] = useState(null); + const router = useRouter(); + useFocusEffect( useCallback(() => { - async function loadNote() { + async function getUserAndLoadNote() { if (typeof noteId !== 'string') { return; } - const response = await fetch(`/api/notes/${noteId}`); - const responseBody: NoteResponseBodyGet = await response.json(); + const [userResponse, noteResponse]: [ + UserResponseBodyGet, + NoteResponseBodyGet, + ] = await Promise.all([ + fetch('/api/user').then((response) => response.json()), + fetch(`/api/notes/${noteId}`).then((response) => response.json()), + ]); + + if ('error' in userResponse) { + router.replace(`/(auth)/login?returnTo=/notes/${noteId}`); + } - if ('note' in responseBody) { - setNote(responseBody.note); + if ('note' in noteResponse) { + setNote(noteResponse.note); } } - loadNote().catch((error) => { + getUserAndLoadNote().catch((error) => { console.error(error); }); - }, [noteId]), + }, [noteId, router]), ); if (typeof noteId !== 'string') { From e6fdd07c7fa8a476d67c6a4b4f8e9f2dabee8914 Mon Sep 17 00:00:00 2001 From: Lukas Prochazka Date: Thu, 7 Nov 2024 09:47:33 +0100 Subject: [PATCH 5/7] Update app.json for dark mode and remove StatusBar --- app.json | 2 +- app/_layout.tsx | 2 -- 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/app.json b/app.json index d0d7f58..f454369 100644 --- a/app.json +++ b/app.json @@ -5,7 +5,7 @@ "version": "1.0.0", "orientation": "portrait", "icon": "./assets/icon.png", - "userInterfaceStyle": "light", + "userInterfaceStyle": "dark", "splash": { "image": "./assets/splash.png", "resizeMode": "contain", diff --git a/app/_layout.tsx b/app/_layout.tsx index f5ffdd9..0b49c7a 100644 --- a/app/_layout.tsx +++ b/app/_layout.tsx @@ -5,7 +5,6 @@ import { } from '@expo-google-fonts/poppins'; import Constants from 'expo-constants'; import { Stack } from 'expo-router'; -import { StatusBar } from 'expo-status-bar'; import { Platform, SafeAreaView, StyleSheet, View } from 'react-native'; import { colors } from '../constants/colors'; @@ -35,7 +34,6 @@ export default function HomeLayout() { return ( - Date: Mon, 18 Nov 2024 17:19:01 +0100 Subject: [PATCH 6/7] Add Yup validation --- app/(auth)/login.tsx | 45 +++++++++++++++++++++ app/(auth)/register.tsx | 47 ++++++++++++++++++++++ app/guests/newGuest.tsx | 46 ++++++++++++++++++++++ app/notes/newNote.tsx | 49 +++++++++++++++++++++-- package.json | 1 + pnpm-lock.yaml | 34 ++++++++++++++++ util/validateToFieldErrors.ts | 74 +++++++++++++++++++++++++++++++++++ 7 files changed, 293 insertions(+), 3 deletions(-) create mode 100644 util/validateToFieldErrors.ts diff --git a/app/(auth)/login.tsx b/app/(auth)/login.tsx index 7cb3961..cae7c06 100644 --- a/app/(auth)/login.tsx +++ b/app/(auth)/login.tsx @@ -15,10 +15,23 @@ import { TextInput, View, } from 'react-native'; +import * as yup from 'yup'; import { colors } from '../../constants/colors'; +import { validateToFieldErrors } from '../../util/validateToFieldErrors'; import type { UserResponseBodyGet } from '../api/user+api'; import type { LoginResponseBodyPost } from './api/login+api'; +const loginSchema = yup.object({ + username: yup + .string() + .min(3, 'Username must be at least 3 characters') + .required('Username is required'), + password: yup + .string() + .min(3, 'Password must be at least 3 characters') + .required('Password is required'), +}); + const styles = StyleSheet.create({ container: { flex: 1, @@ -59,6 +72,14 @@ const styles = StyleSheet.create({ alignItems: 'center', gap: 4, }, + inputError: { + borderColor: 'red', + }, + errorText: { + color: 'red', + fontSize: 14, + marginBottom: 8, + }, button: { marginTop: 30, backgroundColor: colors.text, @@ -82,6 +103,7 @@ const styles = StyleSheet.create({ export default function Login() { const [username, setUsername] = useState(''); const [password, setPassword] = useState(''); + const [fieldErrors, setFieldErrors] = useState>({}); const [focusedInput, setFocusedInput] = useState(); const { returnTo } = useLocalSearchParams<{ returnTo: string }>(); @@ -118,17 +140,23 @@ export default function Login() { style={[ styles.input, focusedInput === 'username' && styles.inputFocused, + fieldErrors.username && styles.inputError, ]} value={username} onChangeText={setUsername} onFocus={() => setFocusedInput('username')} onBlur={() => setFocusedInput(undefined)} /> + {fieldErrors.username && ( + {fieldErrors.username} + )} + Password setFocusedInput('password')} onBlur={() => setFocusedInput(undefined)} /> + {fieldErrors.password && ( + {fieldErrors.password} + )} Don't have an account? @@ -146,6 +177,20 @@ export default function Login() { [styles.button, { opacity: pressed ? 0.5 : 1 }]} onPress={async () => { + const validationResult = await validateToFieldErrors(loginSchema, { + username, + password, + }); + + if ('fieldErrors' in validationResult) { + const errors = Object.fromEntries(validationResult.fieldErrors); + setFieldErrors(errors); + return; + } + + // Clear errors if validation passes + setFieldErrors({}); + const response = await fetch('/api/login', { method: 'POST', body: JSON.stringify({ username, password, attending: false }), diff --git a/app/(auth)/register.tsx b/app/(auth)/register.tsx index a729501..2e83170 100644 --- a/app/(auth)/register.tsx +++ b/app/(auth)/register.tsx @@ -9,9 +9,22 @@ import { TextInput, View, } from 'react-native'; +import * as yup from 'yup'; import { colors } from '../../constants/colors'; +import { validateToFieldErrors } from '../../util/validateToFieldErrors'; import type { RegisterResponseBodyPost } from './api/register+api'; +const registerSchema = yup.object({ + username: yup + .string() + .min(3, 'Username must be at least 3 characters') + .required('Username is required'), + password: yup + .string() + .min(3, 'Password must be at least 3 characters') + .required('Password is required'), +}); + const styles = StyleSheet.create({ container: { flex: 1, @@ -46,6 +59,14 @@ const styles = StyleSheet.create({ inputFocused: { borderColor: colors.white, }, + inputError: { + borderColor: 'red', + }, + errorText: { + color: 'red', + fontSize: 14, + marginBottom: 8, + }, promptTextContainer: { flexDirection: 'row', justifyContent: 'center', @@ -75,6 +96,7 @@ const styles = StyleSheet.create({ export default function Register() { const [username, setUsername] = useState(''); const [password, setPassword] = useState(''); + const [fieldErrors, setFieldErrors] = useState>({}); const [focusedInput, setFocusedInput] = useState(); useFocusEffect( @@ -103,17 +125,23 @@ export default function Register() { style={[ styles.input, focusedInput === 'username' && styles.inputFocused, + fieldErrors.username && styles.inputError, ]} value={username} onChangeText={setUsername} onFocus={() => setFocusedInput('username')} onBlur={() => setFocusedInput(undefined)} /> + {fieldErrors.username && ( + {fieldErrors.username} + )} + Password setFocusedInput('password')} onBlur={() => setFocusedInput(undefined)} /> + {fieldErrors.password && ( + {fieldErrors.password} + )} + Already have an account? @@ -128,9 +160,24 @@ export default function Register() { + [styles.button, { opacity: pressed ? 0.5 : 1 }]} onPress={async () => { + const validationResult = await validateToFieldErrors(registerSchema, { + username, + password, + }); + + if ('fieldErrors' in validationResult) { + const errors = Object.fromEntries(validationResult.fieldErrors); + setFieldErrors(errors); + return; + } + + // Clear errors if validation passes + setFieldErrors({}); + const response = await fetch('/api/register', { method: 'POST', body: JSON.stringify({ username, password, attending: false }), diff --git a/app/guests/newGuest.tsx b/app/guests/newGuest.tsx index ee1943b..fe1eb90 100644 --- a/app/guests/newGuest.tsx +++ b/app/guests/newGuest.tsx @@ -9,9 +9,22 @@ import { TextInput, View, } from 'react-native'; +import * as yup from 'yup'; import { colors } from '../../constants/colors'; +import { validateToFieldErrors } from '../../util/validateToFieldErrors'; import type { GuestsResponseBodyPost } from '../api/guests/index+api'; +const guestSchema = yup.object({ + firstName: yup + .string() + .min(1, 'First name is required') + .max(30, 'First name must be less than 30 characters'), + lastName: yup + .string() + .min(1, 'Last name is required') + .max(30, 'Last name must be less than 30 characters'), +}); + const styles = StyleSheet.create({ container: { flex: 1, @@ -46,6 +59,14 @@ const styles = StyleSheet.create({ inputFocused: { borderColor: colors.white, }, + inputError: { + borderColor: 'red', + }, + errorText: { + color: 'red', + fontSize: 14, + marginBottom: 8, + }, button: { marginTop: 30, backgroundColor: colors.text, @@ -69,6 +90,7 @@ const styles = StyleSheet.create({ export default function NewGuest() { const [firstName, setFirstName] = useState(''); const [lastName, setLastName] = useState(''); + const [fieldErrors, setFieldErrors] = useState>({}); const [focusedInput, setFocusedInput] = useState(); return ( @@ -79,27 +101,51 @@ export default function NewGuest() { style={[ styles.input, focusedInput === 'firstName' && styles.inputFocused, + fieldErrors.firstName && styles.inputError, ]} value={firstName} onChangeText={setFirstName} onFocus={() => setFocusedInput('firstName')} onBlur={() => setFocusedInput(undefined)} /> + {fieldErrors.firstName && ( + {fieldErrors.firstName} + )} + Last Name setFocusedInput('lastName')} onBlur={() => setFocusedInput(undefined)} /> + {fieldErrors.lastName && ( + {fieldErrors.lastName} + )} + [styles.button, { opacity: pressed ? 0.5 : 1 }]} onPress={async () => { + const validationResult = await validateToFieldErrors(guestSchema, { + firstName, + lastName, + }); + + if ('fieldErrors' in validationResult) { + const errors = Object.fromEntries(validationResult.fieldErrors); + setFieldErrors(errors); + return; + } + + // Clear errors if validation passes + setFieldErrors({}); + const response = await fetch('/api/guests', { method: 'POST', body: JSON.stringify({ firstName, lastName, attending: false }), diff --git a/app/notes/newNote.tsx b/app/notes/newNote.tsx index 650a0ac..5840e2c 100644 --- a/app/notes/newNote.tsx +++ b/app/notes/newNote.tsx @@ -9,9 +9,19 @@ import { TextInput, View, } from 'react-native'; +import * as yup from 'yup'; import { colors } from '../../constants/colors'; +import { validateToFieldErrors } from '../../util/validateToFieldErrors'; import type { GuestsResponseBodyPost } from '../api/guests/index+api'; +const noteSchema = yup.object({ + title: yup + .string() + .min(1, 'Title is required') + .max(100, 'Title must be less than 100 characters'), + textContent: yup.string().min(5, 'Text must be at least 5 characters'), +}); + const styles = StyleSheet.create({ container: { flex: 1, @@ -19,7 +29,7 @@ const styles = StyleSheet.create({ alignItems: 'center', width: '100%', }, - addGuestContainer: { + addNoteContainer: { backgroundColor: colors.cardBackground, borderRadius: 12, padding: 12, @@ -46,6 +56,14 @@ const styles = StyleSheet.create({ inputFocused: { borderColor: colors.white, }, + inputError: { + borderColor: 'red', + }, + errorText: { + color: 'red', + fontSize: 14, + marginBottom: 8, + }, button: { marginTop: 30, backgroundColor: colors.text, @@ -69,27 +87,34 @@ const styles = StyleSheet.create({ export default function NewNote() { const [title, setTitle] = useState(''); const [textContent, setTextContent] = useState(''); + const [fieldErrors, setFieldErrors] = useState>({}); const [focusedInput, setFocusedInput] = useState(); return ( - + Title setFocusedInput('title')} onBlur={() => setFocusedInput(undefined)} /> + {fieldErrors.title && ( + {fieldErrors.title} + )} + Text setFocusedInput('textContent')} onBlur={() => setFocusedInput(undefined)} /> + {fieldErrors.textContent && ( + {fieldErrors.textContent} + )} + [styles.button, { opacity: pressed ? 0.5 : 1 }]} onPress={async () => { + const validationResult = await validateToFieldErrors(noteSchema, { + title, + textContent, + }); + + if ('fieldErrors' in validationResult) { + const errors = Object.fromEntries(validationResult.fieldErrors); + setFieldErrors(errors); + return; + } + + // Clear errors if validation passes + setFieldErrors({}); + const response = await fetch('/api/notes', { method: 'POST', body: JSON.stringify({ title, textContent }), }); if (!response.ok) { - let errorMessage = 'Error creating guest'; + let errorMessage = 'Error creating note'; const responseBody: GuestsResponseBodyPost = await response.json(); if ('error' in responseBody) { diff --git a/package.json b/package.json index 8a8a973..215a9f2 100644 --- a/package.json +++ b/package.json @@ -32,6 +32,7 @@ "react-native": "0.75.2", "react-native-web": "^0.19.13", "tsx": "^4.19.1", + "yup": "^1.4.0", "zod": "^3.23.8" }, "devDependencies": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index cc48fba..312d107 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -68,6 +68,9 @@ importers: tsx: specifier: ^4.19.1 version: 4.19.1 + yup: + specifier: ^1.4.0 + version: 1.4.0 zod: specifier: ^3.23.8 version: 3.23.8 @@ -4433,6 +4436,9 @@ packages: prop-types@15.8.1: resolution: {integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==} + property-expr@2.0.6: + resolution: {integrity: sha512-SVtmxhRE/CGkn3eZY1T6pC8Nln6Fr/lu1mKSgRud0eC73whjGfoAogbn78LkD8aFL0zz3bAFerKSnOl7NlErBA==} + pump@3.0.2: resolution: {integrity: sha512-tUPXtzlGM8FE3P0ZL6DVs/3P58k9nk8/jZeQCurTJylQA8qFYzHFfhBJkuqyE0FifOsQ0uKWekiZ5g8wtr28cw==} @@ -5134,6 +5140,9 @@ packages: through@2.3.8: resolution: {integrity: sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg==} + tiny-case@1.0.3: + resolution: {integrity: sha512-Eet/eeMhkO6TX8mnUteS9zgPbUMQa4I6Kkp5ORiBD5476/m+PIRiumP5tmh5ioJpH7k51Kehawy2UDfsnxxY8Q==} + tiny-jsonc@1.0.1: resolution: {integrity: sha512-ik6BCxzva9DoiEfDX/li0L2cWKPPENYvixUprFdl3YPi4bZZUhDnNI9YUkacrv+uIG90dnxR5mNqaoD6UhD6Bw==} @@ -5152,6 +5161,9 @@ packages: resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==} engines: {node: '>=0.6'} + toposort@2.0.2: + resolution: {integrity: sha512-0a5EOkAUp8D4moMi2W8ZF8jcga7BgZd91O/yabJCFY8az+XSzeGyTKs0Aoo897iV1Nj6guFq8orWDS96z91oGg==} + totalist@2.0.0: resolution: {integrity: sha512-+Y17F0YzxfACxTyjfhnJQEe7afPA0GSpYlFkl2VFMxYP7jshQf9gXV7cH47EfToBumFThfKBvfAcoUn6fdNeRQ==} engines: {node: '>=6'} @@ -5239,6 +5251,10 @@ packages: resolution: {integrity: sha512-4dbzIzqvjtgiM5rw1k5rEHtBANKmdudhGyBEajN01fEyhaAIhsoKNy6y7+IN93IfpFtwY9iqi7kD+xwKhQsNJA==} engines: {node: '>=8'} + type-fest@2.19.0: + resolution: {integrity: sha512-RAH822pAdBgcNMAfWnCBU3CFZcfZ/i1eZjwFU/dsLKumyuuP3niueg2UAukXYF0E2AAoc82ZSSf9J0WQBinzHA==} + engines: {node: '>=12.20'} + type-fest@4.26.1: resolution: {integrity: sha512-yOGpmOAL7CkKe/91I5O3gPICmJNLJ1G4zFYVAsRHg7M64biSnPtRj0WNQt++bRkjYOqjWXrhnUw1utzmVErAdg==} engines: {node: '>=16'} @@ -5574,6 +5590,9 @@ packages: resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} engines: {node: '>=10'} + yup@1.4.0: + resolution: {integrity: sha512-wPbgkJRCqIf+OHyiTBQoJiP5PFuAXaWiJK6AmYkzQAh5/c2K9hzSApBZG5wV9KoKSePF7sAxmNSvh/13YHkFDg==} + zod-to-json-schema@3.20.1: resolution: {integrity: sha512-U+zmNJUKqzv92E+LdEYv0g2LxBLks4HAwfC6cue8jXby5PAeSEPGO4xV9Sl4zmLYyFvJkm0FOfOs6orUO+AI1w==} peerDependencies: @@ -11162,6 +11181,8 @@ snapshots: object-assign: 4.1.1 react-is: 16.13.1 + property-expr@2.0.6: {} + pump@3.0.2: dependencies: end-of-stream: 1.4.4 @@ -11970,6 +11991,8 @@ snapshots: through@2.3.8: {} + tiny-case@1.0.3: {} + tiny-jsonc@1.0.1: {} tmp@0.0.33: @@ -11984,6 +12007,8 @@ snapshots: toidentifier@1.0.1: {} + toposort@2.0.2: {} + totalist@2.0.0: {} tr46@0.0.3: {} @@ -12047,6 +12072,8 @@ snapshots: type-fest@0.8.1: {} + type-fest@2.19.0: {} + type-fest@4.26.1: {} typed-array-buffer@1.0.2: @@ -12379,6 +12406,13 @@ snapshots: yocto-queue@0.1.0: {} + yup@1.4.0: + dependencies: + property-expr: 2.0.6 + tiny-case: 1.0.3 + toposort: 2.0.2 + type-fest: 2.19.0 + zod-to-json-schema@3.20.1(zod@3.23.8): dependencies: zod: 3.23.8 diff --git a/util/validateToFieldErrors.ts b/util/validateToFieldErrors.ts new file mode 100644 index 0000000..1aa6711 --- /dev/null +++ b/util/validateToFieldErrors.ts @@ -0,0 +1,74 @@ +import { + type InferType, + Schema as YupSchema, + ValidationError as YupValidationError, +} from 'yup'; + +type ConcatPaths< + Prefix extends string, + Key extends string, +> = `${Prefix}${Prefix extends '' ? '' : '.'}${Key}`; + +type NestedPropertyPaths = { + [PropertyKey in keyof ObjectType]: ObjectType[PropertyKey] extends object + ? NestedPropertyPaths< + ObjectType[PropertyKey], + ConcatPaths> + > + : ConcatPaths>; +}[keyof ObjectType]; + +type FieldError = [ + fieldName: NestedPropertyPaths>, + message: string, +]; + +export type ValidationError = { + message: string; + fieldErrors?: FieldError[]; +}; + +export async function validateToFieldErrors( + schema: Schema, + data: unknown, +): Promise< + | { + data: InferType; + } + | { + fieldErrors: FieldError[]; + } +> { + try { + return { + data: await schema.validate(data, { + // Validate data exhaustively (return all errors) + abortEarly: false, + // Do not cast or transform data + strict: true, + }), + }; + } catch (error) { + if (!('inner' in (error as Record))) { + throw error; + } + + // Return array of all errors that occurred when using + // abortEarly: false + // https://github.com/jquense/yup#:~:text=alternatively%2C%20errors%20will%20have%20all%20of%20the%20messages%20from%20each%20inner%20error. + return { + fieldErrors: (error as YupValidationError).inner.map((innerError) => { + if (!innerError.path) { + throw new Error( + `field path is falsy for error message "${innerError.message}"`, + ); + } + + return [ + innerError.path as NestedPropertyPaths>, + innerError.message, + ]; + }), + }; + } +} From e0a5ec9dbce56cec37df9fa7a90069f0c611bfd2 Mon Sep 17 00:00:00 2001 From: Lukas Prochazka Date: Mon, 18 Nov 2024 17:30:59 +0100 Subject: [PATCH 7/7] Remove empty lines --- app/(auth)/login.tsx | 1 - app/(auth)/register.tsx | 3 --- app/guests/newGuest.tsx | 2 -- app/notes/newNote.tsx | 2 -- 4 files changed, 8 deletions(-) diff --git a/app/(auth)/login.tsx b/app/(auth)/login.tsx index cae7c06..fa9445b 100644 --- a/app/(auth)/login.tsx +++ b/app/(auth)/login.tsx @@ -150,7 +150,6 @@ export default function Login() { {fieldErrors.username && ( {fieldErrors.username} )} - Password {fieldErrors.username} )} - Password {fieldErrors.password} )} - Already have an account? @@ -160,7 +158,6 @@ export default function Register() { - [styles.button, { opacity: pressed ? 0.5 : 1 }]} onPress={async () => { diff --git a/app/guests/newGuest.tsx b/app/guests/newGuest.tsx index fe1eb90..4538c5d 100644 --- a/app/guests/newGuest.tsx +++ b/app/guests/newGuest.tsx @@ -111,7 +111,6 @@ export default function NewGuest() { {fieldErrors.firstName && ( {fieldErrors.firstName} )} - Last Name {fieldErrors.lastName} )} - [styles.button, { opacity: pressed ? 0.5 : 1 }]} onPress={async () => { diff --git a/app/notes/newNote.tsx b/app/notes/newNote.tsx index 5840e2c..59c3ce7 100644 --- a/app/notes/newNote.tsx +++ b/app/notes/newNote.tsx @@ -108,7 +108,6 @@ export default function NewNote() { {fieldErrors.title && ( {fieldErrors.title} )} - Text {fieldErrors.textContent} )} - [styles.button, { opacity: pressed ? 0.5 : 1 }]} onPress={async () => {