Skip to content

Commit

Permalink
feat: add support for actionless forms
Browse files Browse the repository at this point in the history
  • Loading branch information
ivanfilhoz committed May 13, 2024
1 parent c37f4d2 commit 44b7f8d
Show file tree
Hide file tree
Showing 7 changed files with 107 additions and 67 deletions.
34 changes: 34 additions & 0 deletions app/forms/actionless/form.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
'use client'

import { useForm } from '@'
import { actionlessSchema } from './schema'

export const ActionlessForm = () => {
const { connect, bindField, isPending, fieldErrors } = useForm({
schema: actionlessSchema,
onSubmit: async (values) => {
console.log('Submitting form with values:', values)
return true
}
})

return (
<form {...connect()}>
<label htmlFor='name'>Name</label>
<input {...bindField('name')} />
{fieldErrors.name && <pre>{fieldErrors.name.first}</pre>}
<br />
<label htmlFor='message'>Message</label>
<textarea {...bindField('message')} />
{fieldErrors.message && <pre>{fieldErrors.message.first}</pre>}
<br />
<label htmlFor='attachment'>Attachment (optional)</label>
<input type='file' {...bindField('attachment')} />
{fieldErrors.attachment && <pre>{fieldErrors.attachment.first}</pre>}
<button type='submit' disabled={isPending}>
Submit
</button>
<br />
</form>
)
}
7 changes: 7 additions & 0 deletions app/forms/actionless/schema.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import { z } from 'zod'

export const actionlessSchema = z.object({
name: z.string().min(3, 'Name must be at least 3 characters'),
message: z.string().min(10, 'Message must be at least 10 characters'),
attachment: z.instanceof(File).nullable()
})
2 changes: 1 addition & 1 deletion app/forms/example/form.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { useForm } from '@'
import { exampleAction } from './action'
import { exampleSchema } from './schema'

export const HelloForm = () => {
export const ExampleForm = () => {
const { connect, bindField, isPending, error, fieldErrors, response } =
useForm({
action: exampleAction,
Expand Down
13 changes: 5 additions & 8 deletions app/forms/hello/form.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ export const HelloForm = ({
})

console.log({ fieldErrors })
const contacts = getField('contacts') || []

return (
<form {...connect()} className='flex flex-col w-[320px] gap-1 mt-4'>
Expand Down Expand Up @@ -84,11 +85,11 @@ export const HelloForm = ({
<div className='text-sm text-red-500'>{fieldErrors.terms.first}</div>
)}
<label htmlFor='contacts'>Contacts</label>
{getValues().contacts.map((contact, index) => {
{contacts.map((contact, index) => {
const setSubfield = (subfield: 'name' | 'email', value: string) =>
setValues({
...getValues(),
contacts: getValues().contacts.map((c, i) =>
contacts: contacts.map((c, i) =>
i === index ? { ...c, [subfield]: value } : c
)
})
Expand Down Expand Up @@ -119,7 +120,7 @@ export const HelloForm = ({
onClick={() =>
setValues({
...getValues(),
contacts: getValues().contacts.filter((_, i) => i !== index)
contacts: contacts.filter((_, i) => i !== index)
})
}
>
Expand All @@ -131,11 +132,7 @@ export const HelloForm = ({
<button
onClick={(event) => {
event.preventDefault()
setField(
'contacts',
[...(getValues().contacts || []), { name: '', email: '' }],
false
)
setField('contacts', [...contacts, { name: '', email: '' }], false)
}}
>
Add contact
Expand Down
3 changes: 3 additions & 0 deletions app/page.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
'use client'

import { useState } from 'react'
import { ActionlessForm } from './forms/actionless/form'
import { HelloForm } from './forms/hello'

export default function Home() {
Expand Down Expand Up @@ -44,6 +45,8 @@ export default function Home() {
validateOnChange={validateOnChange}
clientValidation={clientValidation}
/>
<hr className='my-4' />
<ActionlessForm />
</main>
)
}
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "safe-form",
"version": "0.6.1",
"version": "0.7.0",
"description": "⚡️ End-to-end type-safety from client to server.",
"main": "dist/safe-form.es.js",
"types": "dist/index.d.ts",
Expand Down
113 changes: 56 additions & 57 deletions src/useForm.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,13 +24,13 @@ import { FormAction, FormFieldErrors, FormInput, FormState } from './types'
type BindableField = HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement

type UseFormParams<Input extends FormInput, FormResponse> = {
action: FormAction<Input, FormResponse>
action?: FormAction<Input, FormResponse>
schema?: Schema<Input>
initialState?: FormState<Input, FormResponse> | null
initialValues?: Partial<Input>
validateOnBlur?: boolean
validateOnChange?: boolean
onSubmit?: (input: Input) => boolean | Promise<boolean>
onSubmit?: (input: Partial<Input>) => boolean | Promise<boolean>
onSuccess?: (response: FormResponse) => void
onError?: (
error: string | null,
Expand All @@ -44,11 +44,11 @@ type UseFormReturn<Input extends FormInput, FormResponse> = {
fieldErrors: FormFieldErrors<Input>
isPending: boolean
isDirty: boolean
getValues: () => Input
getValues: () => Partial<Input>
setValues: (values: Partial<Input>) => void
connect: () => FormHTMLAttributes<HTMLFormElement>
validate: () => boolean
getField: <Field extends keyof Input>(name: Field) => Input[Field]
getField: <Field extends keyof Input>(name: Field) => Input[Field] | undefined
setField: <Field extends keyof Input>(
name: Field,
value: Input[Field],
Expand All @@ -62,7 +62,7 @@ export const useForm = <Input extends FormInput, FormResponse>({
action,
schema,
initialState,
initialValues,
initialValues = {},
validateOnBlur,
validateOnChange,
onSubmit,
Expand All @@ -74,9 +74,12 @@ export const useForm = <Input extends FormInput, FormResponse>({
>(null)
const [isPending, startTransition] = useTransition()
const [isDirty, setIsDirty] = useState(false)
const [formState, formAction] = useFormState(action, initialState ?? null)
const [formState, formAction] = useFormState(
action ?? (() => null),
initialState ?? null
)
const [fieldErrors, setFieldErrors] = useState<FormFieldErrors<Input>>({})
const values = useRef<Input>(initialValues as Input)
const values = useRef<Partial<Input>>(initialValues)
const [flushToggle, setFlushToggle] = useState(false)

const flush = useCallback(() => {
Expand Down Expand Up @@ -116,40 +119,7 @@ export const useForm = <Input extends FormInput, FormResponse>({
// Reset field errors if validation is successful
setFieldErrors({})
return true
}, [setFieldErrors, schema, inputRef])

const getField = useCallback(
<Field extends keyof Input>(name: Field) => {
return values.current[name]
},
[values.current]
)

const setField = useCallback(
<Field extends keyof Input>(
name: keyof Input,
value: Input[Field],
validate: boolean = true
) => {
// Set the dirty state if the value has changed
if (value !== values.current[name]) {
setIsDirty(true)
}

values.current = {
...values.current,
[name]: value
}

// Either validate or just flush the state
if (validate) {
validateField(name)
} else {
flush()
}
},
[inputRef, flush]
)
}, [setFieldErrors, schema])

const validateField = useCallback(
<Field extends keyof Input>(name: Field) => {
Expand Down Expand Up @@ -186,7 +156,37 @@ export const useForm = <Input extends FormInput, FormResponse>({
// The field is valid
return true
},
[setFieldErrors, schema, inputRef, values.current]
[setFieldErrors, schema]
)

const getField = useCallback(<Field extends keyof Input>(name: Field) => {
return values.current[name]
}, [])

const setField = useCallback(
<Field extends keyof Input>(
name: keyof Input,
value: Input[Field],
validate: boolean = true
) => {
// Set the dirty state if the value has changed
if (value !== values.current[name]) {
setIsDirty(true)
}

values.current = {
...values.current,
[name]: value
}

// Either validate or just flush the state
if (validate) {
validateField(name)
} else {
flush()
}
},
[flush, validateField]
)

const bindField = useCallback(
Expand All @@ -197,7 +197,7 @@ export const useForm = <Input extends FormInput, FormResponse>({

inputRef.current[name] = createRef()

const mutate = (name: keyof Input) => {
const mutate = (name: keyof Input, validate: boolean = true) => {
const ref = inputRef.current?.[name]
if (!ref?.current) return

Expand All @@ -210,12 +210,14 @@ export const useForm = <Input extends FormInput, FormResponse>({
ref: inputRef.current[name],
name: name.toString(),
defaultValue: initialValues?.[name] ?? '',
onBlur: validateOnBlur ? () => mutate(name) : undefined,
onBlur: () => {
mutate(name, validateOnBlur)
},
onChange: validateOnChange ? () => mutate(name) : undefined
} satisfies InputHTMLAttributes<HTMLInputElement> &
RefAttributes<BindableField>
},
[inputRef, setField, validateOnBlur, validateOnChange]
[inputRef, setField, validateOnBlur, validateOnChange, initialValues]
)

const connect = useCallback(() => {
Expand All @@ -226,16 +228,19 @@ export const useForm = <Input extends FormInput, FormResponse>({
// Reset field errors
setFieldErrors({})

// Validate all fields before submitting
if (!validate()) {
return
}

// If there is an onSubmit callback, call it
if (onSubmit) {
const shouldSubmit = await onSubmit(values.current)
if (!shouldSubmit) return
}

// Validate all fields before submitting
if (!validate()) {
return
}
// If there is no action, skip the submission
if (!action) return

// Create a FormData object from the values
const formData = createFormData(values.current)
Expand All @@ -247,14 +252,7 @@ export const useForm = <Input extends FormInput, FormResponse>({
},
action: formAction
} satisfies Pick<FormHTMLAttributes<HTMLFormElement>, 'onSubmit' | 'action'>
}, [
values.current,
validate,
setFieldErrors,
formAction,
startTransition,
formAction
])
}, [validate, setFieldErrors, startTransition, formAction, action, onSubmit])

useEffect(() => {
if (formState?.error || formState?.fieldErrors) {
Expand All @@ -264,6 +262,7 @@ export const useForm = <Input extends FormInput, FormResponse>({
setIsDirty(false)
onSuccess?.(formState.response)
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [formState])

return {
Expand Down

0 comments on commit 44b7f8d

Please sign in to comment.