Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Update-conform #2

Merged
merged 2 commits into from
Feb 1, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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