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, + ]; + }), + }; + } +}