Skip to content

Commit

Permalink
Merge pull request #2 from itsmegood:update-conform
Browse files Browse the repository at this point in the history
Update-conform
  • Loading branch information
itsmegood authored Feb 1, 2024
2 parents d40ce1e + 1d76aa3 commit 020482b
Show file tree
Hide file tree
Showing 27 changed files with 521 additions and 495 deletions.
2 changes: 1 addition & 1 deletion app/components/error-boundary.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ export function GeneralErrorBoundary({
? (statusHandlers?.[error.status] ?? defaultStatusHandler)({
error,
params,
})
})
: unexpectedErrorHandler(error)}
</div>
)
Expand Down
39 changes: 21 additions & 18 deletions app/components/forms.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { useInputEvent } from '@conform-to/react'
import React, { useId, useRef } from 'react'
import { useInputControl } from '@conform-to/react'
import React, { useId } from 'react'
import { Checkbox, type CheckboxProps } from './ui/checkbox.tsx'
import { Input } from './ui/input.tsx'
import { Label } from './ui/label.tsx'
Expand Down Expand Up @@ -94,42 +94,45 @@ export function CheckboxField({
className,
}: {
labelProps: JSX.IntrinsicElements['label']
buttonProps: CheckboxProps
buttonProps: CheckboxProps & {
name: string
form: string
value?: string
}
errors?: ListOfErrors
className?: string
}) {
const { key, defaultChecked, ...checkboxProps } = buttonProps
const fallbackId = useId()
const buttonRef = useRef<HTMLButtonElement>(null)
// To emulate native events that Conform listen to:
// See https://conform.guide/integrations
const control = useInputEvent({
// Retrieve the checkbox element by name instead as Radix does not expose the internal checkbox element
// See https://github.com/radix-ui/primitives/discussions/874
ref: () =>
buttonRef.current?.form?.elements.namedItem(buttonProps.name ?? ''),
onFocus: () => buttonRef.current?.focus(),
const checkedValue = buttonProps.value ?? 'on'
const input = useInputControl({
key,
name: buttonProps.name,
formId: buttonProps.form,
initialValue: defaultChecked ? checkedValue : undefined,
})
const id = buttonProps.id ?? buttonProps.name ?? fallbackId
const id = buttonProps.id ?? fallbackId
const errorId = errors?.length ? `${id}-error` : undefined

return (
<div className={className}>
<div className="flex gap-2">
<Checkbox
{...checkboxProps}
id={id}
ref={buttonRef}
aria-invalid={errorId ? true : undefined}
aria-describedby={errorId}
{...buttonProps}
checked={input.value === checkedValue}
onCheckedChange={state => {
control.change(Boolean(state.valueOf()))
input.change(state.valueOf() ? checkedValue : '')
buttonProps.onCheckedChange?.(state)
}}
onFocus={event => {
control.focus()
input.focus()
buttonProps.onFocus?.(event)
}}
onBlur={event => {
control.blur()
input.blur()
buttonProps.onBlur?.(event)
}}
type="button"
Expand Down
31 changes: 15 additions & 16 deletions app/root.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { useForm } from '@conform-to/react'
import { parse } from '@conform-to/zod'
import { getFormProps, useForm } from '@conform-to/react'
import { invariantResponse } from '@epic-web/invariant'
import { cssBundleHref } from '@remix-run/css-bundle'
import {
json,
Expand All @@ -25,7 +25,6 @@ import { withSentry } from '@sentry/remix'
import { HoneypotProvider } from 'remix-utils/honeypot/react'
import { z } from 'zod'
import { GeneralErrorBoundary } from './components/error-boundary.tsx'
import { ErrorList } from './components/forms.tsx'
import { EpicProgress } from './components/progress-bar.tsx'
import { useToast } from './components/toaster.tsx'
import { Icon, href as iconsHref } from './components/ui/icon.tsx'
Expand All @@ -42,6 +41,7 @@ import { useRequestInfo } from './utils/request-info.ts'
import { type Theme, setTheme, getTheme } from './utils/theme.server.ts'
import { makeTimings, time } from './utils/timing.server.ts'
import { getToast } from './utils/toast.server.ts'
import { parseWithZod } from '@conform-to/zod'

export const links: LinksFunction = () => {
return [
Expand Down Expand Up @@ -153,21 +153,18 @@ const ThemeFormSchema = z.object({

export async function action({ request }: ActionFunctionArgs) {
const formData = await request.formData()
const submission = parse(formData, {
const submission = parseWithZod(formData, {
schema: ThemeFormSchema,
})
if (submission.intent !== 'submit') {
return json({ status: 'idle', submission } as const)
}
if (!submission.value) {
return json({ status: 'error', submission } as const, { status: 400 })
}

invariantResponse(submission.status === 'success', 'Invalid theme received')

const { theme } = submission.value

const responseInit = {
headers: { 'set-cookie': setTheme(theme) },
}
return json({ success: true, submission }, responseInit)
return json({ result: submission.reply() }, responseInit)
}

function Document({
Expand Down Expand Up @@ -282,10 +279,13 @@ export function useOptimisticThemeMode() {
const themeFetcher = fetchers.find(f => f.formAction === '/')

if (themeFetcher && themeFetcher.formData) {
const submission = parse(themeFetcher.formData, {
const submission = parseWithZod(themeFetcher.formData, {
schema: ThemeFormSchema,
})
return submission.value?.theme

if (submission.status === 'success') {
return submission.value.theme
}
}
}

Expand All @@ -294,7 +294,7 @@ function ThemeSwitch({ userPreference }: { userPreference?: Theme | null }) {

const [form] = useForm({
id: 'theme-switch',
lastSubmission: fetcher.data?.submission,
lastResult: fetcher.data?.result,
})

const optimisticMode = useOptimisticThemeMode()
Expand All @@ -320,7 +320,7 @@ function ThemeSwitch({ userPreference }: { userPreference?: Theme | null }) {
}

return (
<fetcher.Form method="POST" {...form.props}>
<fetcher.Form method="POST" {...getFormProps(form)}>
<input type="hidden" name="theme" value={nextMode} />
<div className="flex gap-2">
<button
Expand All @@ -330,7 +330,6 @@ function ThemeSwitch({ userPreference }: { userPreference?: Theme | null }) {
{modeLabel[mode]}
</button>
</div>
<ErrorList errors={form.errors} id={form.errorId} />
</fetcher.Form>
)
}
Expand Down
34 changes: 18 additions & 16 deletions app/routes/_auth+/forgot-password.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { conform, useForm } from '@conform-to/react'
import { getFieldsetConstraint, parse } from '@conform-to/zod'
import { getFormProps, getInputProps, useForm } from '@conform-to/react'
import { getZodConstraint, parseWithZod } from '@conform-to/zod'
import * as E from '@react-email/components'
import {
json,
Expand All @@ -26,7 +26,7 @@ const ForgotPasswordSchema = z.object({
export async function action({ request }: ActionFunctionArgs) {
const formData = await request.formData()
checkHoneypot(formData)
const submission = await parse(formData, {
const submission = await parseWithZod(formData, {
schema: ForgotPasswordSchema.superRefine(async (data, ctx) => {
const user = await prisma.user.findFirst({
where: {
Expand All @@ -48,11 +48,11 @@ export async function action({ request }: ActionFunctionArgs) {
}),
async: true,
})
if (submission.intent !== 'submit') {
return json({ status: 'idle', submission } as const)
}
if (!submission.value) {
return json({ status: 'error', submission } as const, { status: 400 })
if (submission.status !== 'success') {
return json(
{ result: submission.reply() },
{ status: submission.status === 'error' ? 400 : 200 },
)
}
const { usernameOrEmail } = submission.value

Expand All @@ -79,8 +79,10 @@ export async function action({ request }: ActionFunctionArgs) {
if (response.status === 'success') {
return redirect(redirectTo.toString())
} else {
submission.error[''] = [response.error.message]
return json({ status: 'error', submission } as const, { status: 500 })
return json(
{ result: submission.reply({ formErrors: [response.error.message] }) },
{ status: 500 },
)
}
}

Expand Down Expand Up @@ -120,10 +122,10 @@ export default function ForgotPasswordRoute() {

const [form, fields] = useForm({
id: 'forgot-password-form',
constraint: getFieldsetConstraint(ForgotPasswordSchema),
lastSubmission: forgotPassword.data?.submission,
constraint: getZodConstraint(ForgotPasswordSchema),
lastResult: forgotPassword.data?.result,
onValidate({ formData }) {
return parse(formData, { schema: ForgotPasswordSchema })
return parseWithZod(formData, { schema: ForgotPasswordSchema })
},
shouldRevalidate: 'onBlur',
})
Expand All @@ -138,7 +140,7 @@ export default function ForgotPasswordRoute() {
</p>
</div>
<div className="mx-auto mt-16 min-w-full max-w-sm sm:min-w-[368px]">
<forgotPassword.Form method="POST" {...form.props}>
<forgotPassword.Form method="POST" {...getFormProps(form)}>
<HoneypotInputs />
<div>
<Field
Expand All @@ -148,7 +150,7 @@ export default function ForgotPasswordRoute() {
}}
inputProps={{
autoFocus: true,
...conform.input(fields.usernameOrEmail),
...getInputProps(fields.usernameOrEmail, { type: 'text' }),
}}
errors={fields.usernameOrEmail.errors}
/>
Expand All @@ -161,7 +163,7 @@ export default function ForgotPasswordRoute() {
status={
forgotPassword.state === 'submitting'
? 'pending'
: forgotPassword.data?.status ?? 'idle'
: form.status ?? 'idle'
}
type="submit"
disabled={forgotPassword.state !== 'idle'}
Expand Down
45 changes: 22 additions & 23 deletions app/routes/_auth+/login.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { conform, useForm } from '@conform-to/react'
import { getFieldsetConstraint, parse } from '@conform-to/zod'
import { useForm, getFormProps, getInputProps } from '@conform-to/react'
import { getZodConstraint, parseWithZod } from '@conform-to/zod'
import { invariant } from '@epic-web/invariant'
import {
json,
Expand Down Expand Up @@ -110,7 +110,10 @@ export async function handleVerification({
request,
submission,
}: VerifyFunctionArgs) {
invariant(submission.value, 'Submission should have a value by this point')
invariant(
submission.status === 'success',
'Submission should be successful by now',
)
const authSession = await authSessionStorage.getSession(
request.headers.get('cookie'),
)
Expand Down Expand Up @@ -196,10 +199,10 @@ export async function action({ request }: ActionFunctionArgs) {
await requireAnonymous(request)
const formData = await request.formData()
checkHoneypot(formData)
const submission = await parse(formData, {
const submission = await parseWithZod(formData, {
schema: intent =>
LoginFormSchema.transform(async (data, ctx) => {
if (intent !== 'submit') return { ...data, session: null }
if (intent !== null) return { ...data, session: null }

const session = await login(data)
if (!session) {
Expand All @@ -214,16 +217,12 @@ export async function action({ request }: ActionFunctionArgs) {
}),
async: true,
})
// get the password off the payload that's sent back
delete submission.payload.password

if (submission.intent !== 'submit') {
// @ts-expect-error - conform should probably have support for doing this
delete submission.value?.password
return json({ status: 'idle', submission } as const)
}
if (!submission.value?.session) {
return json({ status: 'error', submission } as const, { status: 400 })
if (submission.status !== 'success' || !submission.value.session) {
return json(
{ result: submission.reply({ hideFields: ['password'] }) },
{ status: submission.status === 'error' ? 400 : 200 },
)
}

const { session, remember, redirectTo } = submission.value
Expand All @@ -244,11 +243,11 @@ export default function LoginPage() {

const [form, fields] = useForm({
id: 'login-form',
constraint: getFieldsetConstraint(LoginFormSchema),
constraint: getZodConstraint(LoginFormSchema),
defaultValue: { redirectTo },
lastSubmission: actionData?.submission,
lastResult: actionData?.result,
onValidate({ formData }) {
return parse(formData, { schema: LoginFormSchema })
return parseWithZod(formData, { schema: LoginFormSchema })
},
shouldRevalidate: 'onBlur',
})
Expand All @@ -266,12 +265,12 @@ export default function LoginPage() {

<div>
<div className="mx-auto w-full max-w-md px-8">
<Form method="POST" {...form.props}>
<Form method="POST" {...getFormProps(form)}>
<HoneypotInputs />
<Field
labelProps={{ children: 'Username' }}
inputProps={{
...conform.input(fields.username),
...getInputProps(fields.username, { type: 'text' }),
autoFocus: true,
className: 'lowercase',
autoComplete: 'username',
Expand All @@ -282,7 +281,7 @@ export default function LoginPage() {
<Field
labelProps={{ children: 'Password' }}
inputProps={{
...conform.input(fields.password, {
...getInputProps(fields.password, {
type: 'password',
}),
autoComplete: 'current-password',
Expand All @@ -296,7 +295,7 @@ export default function LoginPage() {
htmlFor: fields.remember.id,
children: 'Remember me',
}}
buttonProps={conform.input(fields.remember, {
buttonProps={getInputProps(fields.remember, {
type: 'checkbox',
})}
errors={fields.remember.errors}
Expand All @@ -312,14 +311,14 @@ export default function LoginPage() {
</div>

<input
{...conform.input(fields.redirectTo, { type: 'hidden' })}
{...getInputProps(fields.redirectTo, { type: 'hidden' })}
/>
<ErrorList errors={form.errors} id={form.errorId} />

<div className="flex items-center justify-between gap-6 pt-3">
<StatusButton
className="w-full"
status={isPending ? 'pending' : actionData?.status ?? 'idle'}
status={isPending ? 'pending' : form.status ?? 'idle'}
type="submit"
disabled={isPending}
>
Expand Down
Loading

0 comments on commit 020482b

Please sign in to comment.