Skip to content

Commit

Permalink
feat: Improve useFormState() type safety
Browse files Browse the repository at this point in the history
  • Loading branch information
codinsonn committed Dec 10, 2023
1 parent c87fab3 commit 63553e0
Show file tree
Hide file tree
Showing 4 changed files with 98 additions and 20 deletions.
12 changes: 9 additions & 3 deletions features/cv-page/forms/useResumeDataForm.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,18 @@
import { useFormState } from 'aetherspace/forms/useFormState'
import { ResumeData } from 'cv-page/schemas/ResumeData'
import { GetResumeDataByUserSlugDataBridge } from '../schemas/GetResumeDataByUserSlugDataBridge'
import { DeepPartial } from 'aetherspace/types/typeHelpers'

/* --- Types ----------------------------------------------------------------------------------- */

type InitialFormData = DeepPartial<(typeof GetResumeDataByUserSlugDataBridge)['RES']>

/* --- useResumeDataForm() --------------------------------------------------------------------- */

const useResumeDataForm = (initialValues) => {
const useResumeDataForm = (initialValues: InitialFormData) => {
const formState = useFormState({
stateSchema: ResumeData,
stateSchema: GetResumeDataByUserSlugDataBridge.responseSchema,
initialValues,
applyDefaults: true,
})
return formState
}
Expand Down
26 changes: 22 additions & 4 deletions features/cv-page/screens/UpdateResumeScreen.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
import React from 'react'
import { useAetherRoute } from 'aetherspace/navigation'
import { z, AetherProps, createDataBridge } from 'aetherspace/schemas'
import { GetResumeDataByUserSlugDataBridge } from '..//schemas/GetResumeDataByUserSlugDataBridge.ts'
import { GetResumeDataByUserSlugDataBridge } from '../schemas/GetResumeDataByUserSlugDataBridge.ts'
import { View } from 'aetherspace/primitives'
import { H1, P } from 'aetherspace/html-elements'
import useResumeDataForm from '../forms/useResumeDataForm.ts'
import { dummyResumeData } from '../mocks/resumeData.mock.ts'
import { isEmpty } from 'aetherspace/utils/commonUtils/commonUtils.ts'

/* --- Schemas & Types ------------------------------------------------------------------------- */

Expand Down Expand Up @@ -42,9 +43,18 @@ export const UpdateResumeScreen = (props: TUpdateResumeScreenProps) => {

// -- Effects --

React.useEffect(() => {
formState.handleChange('slug', 'codinsonnn')
}, [])
// React.useEffect(() => {
// formState.handleChange('slug', 'codinsonnn')
// }, [])

// React.useEffect(() => {
// formState.validate()
// }, [formState.getValue('slug')])

// React.useEffect(() => {
// const { errors } = formState
// console.log('ResumeScreen', { formState })
// }, [formState.hasErrors('awards')])

// -- Guards --

Expand All @@ -56,6 +66,14 @@ export const UpdateResumeScreen = (props: TUpdateResumeScreenProps) => {
)
}

if (!isEmpty(formState.errors)) {
return (
<View tw="w-full h-full items-center justify-center">
<H1 tw="text-red-500">Form error: {JSON.stringify(formState.errors, null, 4)}</H1>
</View>
)
}

// -- Render --

return (
Expand Down
70 changes: 57 additions & 13 deletions packages/@aetherspace/forms/useFormState.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
/* eslint-disable @typescript-eslint/ban-types */
import { useState, useMemo, useEffect } from 'react'
// Types
import { DeepPartial, HintedKeys } from '../types'
// Schemas
import { z } from '../schemas'
// Utils
Expand All @@ -18,46 +20,76 @@ export type AetherFormInputBaseProps<T> = {
}

export type AetherFormState<T = Record<string, any>, K extends keyof T = keyof T> = {
errors: Record<K, string[]>
/** -i- The current values of the form */
values: T
/** -i- A map of all errors in the form */
errors: Record<K, string[]>
/** -i- Whether the form is valid or not */
isValid: boolean
/** -i- Whether the form has been changed from its default state */
isUnsaved: boolean
/** -i- Whether the form is currently the default state */
isDefaultState: boolean
/** -i- Sets the form state to the provided values */
setValues: (values: T) => void
/** -i- Sets a single value in the form state */
handleChange: (key: K, value: T[K]) => void
/** -i- Returns a handler that sets a single value in the form state */
getChangeHandler: (key: K) => (value: T[K]) => void
/** -i- Validates the form state, sets errors if not, and returns whether it is valid or not */
validate: (showErrors?: boolean) => boolean
/** -i- Updates the form errors */
updateErrors: (errors: Record<K, string[]>) => void
/** -i- Resets the form state to its default values */
resetForm: () => void
/** -i- Spreadable function that returns the props to add to an input to hook it up to the form state */
getInputProps: (key: K) => AetherFormInputBaseProps<T[K]>
transformValues: (values: T) => Record<K | (string & {}), unknown>
/** -i- Transform the values of the form (simply returns it if no transformer was provided) */
transformValues: (values: T) => Record<K | HintedKeys, unknown>
/** -i- Returns a single value from the form state */
getValue: (key: K) => T[K]
/** -i- Returns the errors for a single value from the form state */
getErrors: (key: K) => string[]
/** -i- Returns whether a single value from the form state has errors */
hasErrors: (key: K) => boolean
}

export type AetherFormStateOptions<S extends z.ZodRawShape, T extends z.infer<z.ZodObject<S>>> = {
/** -i- Schema to validate the state with *and* provide types from */
stateSchema: z.ZodObject<S>
initialValues?: Record<keyof T | (string & {}), unknown>
/** -i- Initial values to set the form state to, should be a partial of the possible formState */
initialValues?: DeepPartial<Record<keyof T | HintedKeys, unknown>>
/** -i- Will trigger revalidation once a field loses focus */
validateOnBlur?: boolean
/** -i- Will trigger revalidation once a field changes */
validateOnChange?: boolean
transformValues?: (values: T) => Record<keyof T | (string & {}), unknown>
/** -i- Applies state schema defaults to the form state on every change */
applyDefaults?: boolean
/** -i- If provided, will transform the values before returning them when calling formState.transformValues() */
transformValues?: (values: T) => Record<keyof T | HintedKeys, unknown>
}

/** --- useFormState() ------------------------------------------------------------------------- */
/** -i- Returns a set of form management tools to handle form state, including validation, errors and even the required props to add to inputs */
export const useFormState = <S extends z.ZodRawShape, T extends z.infer<z.ZodObject<S>>>(
options: AetherFormStateOptions<S, T>
): AetherFormState<T> => {
// Options
const { stateSchema, initialValues, validateOnBlur, validateOnChange } = options
const defaultValues = useMemo(() => stateSchema.applyDefaults(initialValues ?? {}) as T, [])

// Types
type K = keyof T

// Options
const { stateSchema, initialValues, validateOnBlur, validateOnChange } = options // prettier-ignore

// Defaults
const defaultValues = useMemo(() => {
if (options.applyDefaults) return initialValues as T
return stateSchema.applyDefaults(initialValues ?? {}) as T
}, [])

// State
const [values, setValues] = useState<T>(defaultValues)
const [errors, updateErrors] = useState<Record<K, string[]>>({} as Record<K, string[]>)
const valuesToReturn = options.applyDefaults ? stateSchema.applyDefaults(values) : values

// -- Memos --

Expand All @@ -75,16 +107,25 @@ export const useFormState = <S extends z.ZodRawShape, T extends z.infer<z.ZodObj

const hasErrors = (key: K) => !isEmpty(errors[key])

const transformValues = () => (options.transformValues?.(values) ?? (values as Record<K | (string & {}), unknown>)) // prettier-ignore
const transformValues = () => {
const transformedValues = options.transformValues?.(valuesToReturn)
return transformedValues ?? (valuesToReturn as Record<K | (string & {}), unknown>)
}

// -- Validation --

const validate = (showErrors = true) => {
const validationResult = stateSchema.safeParse(values) // @ts-ignore
const validationResult = stateSchema.safeParse(valuesToReturn) // @ts-ignore
const validationError = validationResult.error as z.ZodError | undefined
if (validationError && showErrors) {
const formErrors = validationError.flatten().fieldErrors as Record<K, string[]>
updateErrors(formErrors)
const flattenedErrors = validationError.flatten()
const formErrors = flattenedErrors.fieldErrors as Record<K, string[]>
const formErrorKeys = Object.keys(formErrors) as K[]
const updatedErrors = formErrorKeys.reduce((acc, key) => {
const dedupedErrors = Array.from(new Set(formErrors[key]))
return { ...acc, [key]: dedupedErrors }
}, {} as Record<K, string[]>) // prettier-ignore
updateErrors(updatedErrors)
}
return validationResult.success
}
Expand All @@ -95,6 +136,8 @@ export const useFormState = <S extends z.ZodRawShape, T extends z.infer<z.ZodObj
setValues((currentValues) => ({ ...currentValues, [key]: value }))
}

const getChangeHandler = (key: K) => (value: T[K]) => handleChange(key, value)

// -- Input Props --

const getInputProps = (key: K) => ({
Expand All @@ -120,13 +163,14 @@ export const useFormState = <S extends z.ZodRawShape, T extends z.infer<z.ZodObj
// -- Return --

return {
values,
values: valuesToReturn,
errors,
isDefaultState,
isValid,
isUnsaved,
setValues,
handleChange,
getChangeHandler,
validate,
updateErrors,
resetForm,
Expand Down
10 changes: 10 additions & 0 deletions packages/@aetherspace/types/typeHelpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,4 +5,14 @@ export type Unpromisify<T> = T extends Promise<infer R> ? R : T

export type HintedKeys = string & {} // eslint-disable-line @typescript-eslint/ban-types

export type DeepPartial<T> = {
[P in keyof T]?: T[P] extends Array<infer U>
? Array<DeepPartial<U>>
: T[P] extends ReadonlyArray<infer U>
? ReadonlyArray<DeepPartial<U>>
: T[P] extends object
? DeepPartial<T[P]>
: T[P]
}

export type any$Todo = any

0 comments on commit 63553e0

Please sign in to comment.