From 0758042248865a81d437678a3b770b17909622d7 Mon Sep 17 00:00:00 2001 From: itsmegood Date: Thu, 1 Feb 2024 13:31:08 +0530 Subject: [PATCH 1/2] init --- app/routes/_auth+/forgot-password.tsx | 34 +-- app/routes/_auth+/login.tsx | 45 ++-- app/routes/_auth+/onboarding.tsx | 47 ++-- app/routes/_auth+/onboarding_.$provider.tsx | 56 +++-- app/routes/_auth+/reset-password.tsx | 45 ++-- app/routes/_auth+/signup.tsx | 38 +-- app/routes/_auth+/verify.tsx | 57 +++-- app/routes/_marketing+/index.tsx | 19 +- app/routes/admin+/cache.tsx | 2 +- app/routes/admin+/cache_.lru.$cacheKey.ts | 2 +- app/routes/admin+/cache_.sqlite.$cacheKey.ts | 2 +- app/routes/resources+/download-user-data.tsx | 2 +- app/routes/settings+/profile.change-email.tsx | 57 +++-- app/routes/settings+/profile.index.tsx | 41 ++-- app/routes/settings+/profile.password.tsx | 40 ++-- .../settings+/profile.password_.create.tsx | 38 +-- app/routes/settings+/profile.photo.tsx | 48 ++-- .../settings+/profile.two-factor.verify.tsx | 46 ++-- .../users+/$username_+/__note-editor.tsx | 225 +++++++++--------- .../users+/$username_+/notes.$noteId.tsx | 22 +- 20 files changed, 444 insertions(+), 422 deletions(-) diff --git a/app/routes/_auth+/forgot-password.tsx b/app/routes/_auth+/forgot-password.tsx index fb249cc..5559b95 100644 --- a/app/routes/_auth+/forgot-password.tsx +++ b/app/routes/_auth+/forgot-password.tsx @@ -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, @@ -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: { @@ -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 @@ -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 }, + ) } } @@ -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', }) @@ -138,7 +140,7 @@ export default function ForgotPasswordRoute() {

- +
@@ -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'} diff --git a/app/routes/_auth+/login.tsx b/app/routes/_auth+/login.tsx index e56e2b7..e3edfaf 100644 --- a/app/routes/_auth+/login.tsx +++ b/app/routes/_auth+/login.tsx @@ -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, @@ -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'), ) @@ -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) { @@ -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 @@ -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', }) @@ -266,12 +265,12 @@ export default function LoginPage() {
-
+
diff --git a/app/routes/_auth+/onboarding.tsx b/app/routes/_auth+/onboarding.tsx index cefb399..6752145 100644 --- a/app/routes/_auth+/onboarding.tsx +++ b/app/routes/_auth+/onboarding.tsx @@ -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 { invariant } from '@epic-web/invariant' import { json, @@ -69,7 +69,7 @@ export async function action({ request }: ActionFunctionArgs) { const email = await requireOnboardingEmail(request) const formData = await request.formData() checkHoneypot(formData) - const submission = await parse(formData, { + const submission = await parseWithZod(formData, { schema: intent => SignupFormSchema.superRefine(async (data, ctx) => { const existingUser = await prisma.user.findUnique({ @@ -85,7 +85,7 @@ export async function action({ request }: ActionFunctionArgs) { return } }).transform(async data => { - if (intent !== 'submit') return { ...data, session: null } + if (intent !== null) return { ...data, session: null } const session = await signup({ ...data, email }) return { ...data, session } @@ -93,11 +93,11 @@ export async function action({ request }: ActionFunctionArgs) { async: true, }) - if (submission.intent !== 'submit') { - 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() }, + { status: submission.status === 'error' ? 400 : 200 }, + ) } const { session, remember, redirectTo } = submission.value @@ -127,7 +127,10 @@ export async function action({ request }: ActionFunctionArgs) { } export async function handleVerification({ submission }: VerifyFunctionArgs) { - invariant(submission.value, 'submission.value should be defined by now') + invariant( + submission.status === 'success', + 'Submission should be successful by now', + ) const verifySession = await verifySessionStorage.getSession() verifySession.set(onboardingEmailSessionKey, submission.value.target) return redirect('/onboarding', { @@ -150,11 +153,11 @@ export default function SignupRoute() { const [form, fields] = useForm({ id: 'onboarding-form', - constraint: getFieldsetConstraint(SignupFormSchema), + constraint: getZodConstraint(SignupFormSchema), defaultValue: { redirectTo }, - lastSubmission: actionData?.submission, + lastResult: actionData?.result, onValidate({ formData }) { - return parse(formData, { schema: SignupFormSchema }) + return parseWithZod(formData, { schema: SignupFormSchema }) }, shouldRevalidate: 'onBlur', }) @@ -172,13 +175,13 @@ export default function SignupRoute() { - +
diff --git a/app/routes/_auth+/onboarding_.$provider.tsx b/app/routes/_auth+/onboarding_.$provider.tsx index 8c49609..be4df71 100644 --- a/app/routes/_auth+/onboarding_.$provider.tsx +++ b/app/routes/_auth+/onboarding_.$provider.tsx @@ -1,5 +1,10 @@ -import { conform, useForm } from '@conform-to/react' -import { getFieldsetConstraint, parse } from '@conform-to/zod' +import { + type SubmissionResult, + getFormProps, + getInputProps, + useForm, +} from '@conform-to/react' +import { getZodConstraint, parseWithZod } from '@conform-to/zod' import { invariant } from '@epic-web/invariant' import { json, @@ -94,12 +99,12 @@ export async function loader({ request, params }: LoaderFunctionArgs) { email, status: 'idle', submission: { - intent: '', - payload: (prefilledProfile ?? {}) as Record, + status: 'error', + initialValue: prefilledProfile ?? {}, error: { '': typeof formError === 'string' ? [formError] : [], }, - }, + } as SubmissionResult, }) } @@ -113,7 +118,7 @@ export async function action({ request, params }: ActionFunctionArgs) { request.headers.get('cookie'), ) - const submission = await parse(formData, { + const submission = await parseWithZod(formData, { schema: SignupFormSchema.superRefine(async (data, ctx) => { const existingUser = await prisma.user.findUnique({ where: { username: data.username }, @@ -139,11 +144,11 @@ export async function action({ request, params }: ActionFunctionArgs) { async: true, }) - if (submission.intent !== 'submit') { - return json({ status: 'idle', submission } as const) - } - if (!submission.value?.session) { - 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 { session, remember, redirectTo } = submission.value @@ -172,7 +177,10 @@ export async function action({ request, params }: ActionFunctionArgs) { } export async function handleVerification({ submission }: VerifyFunctionArgs) { - invariant(submission.value, 'submission.value should be defined by now') + invariant( + submission.status === 'success', + 'Submission should be successful by now', + ) const verifySession = await verifySessionStorage.getSession() verifySession.set(onboardingEmailSessionKey, submission.value.target) return redirect('/onboarding', { @@ -195,10 +203,10 @@ export default function SignupRoute() { const [form, fields] = useForm({ id: 'onboarding-provider-form', - constraint: getFieldsetConstraint(SignupFormSchema), - lastSubmission: actionData?.submission ?? data.submission, + constraint: getZodConstraint(SignupFormSchema), + lastResult: actionData?.result ?? data.submission, onValidate({ formData }) { - return parse(formData, { schema: SignupFormSchema }) + return parseWithZod(formData, { schema: SignupFormSchema }) }, shouldRevalidate: 'onBlur', }) @@ -216,25 +224,25 @@ export default function SignupRoute() { - {fields.imageUrl.defaultValue ? ( + {fields.imageUrl.initialValue ? (
Profile

You can change your photo later

- +
) : null} @@ -279,7 +287,7 @@ export default function SignupRoute() {
diff --git a/app/routes/_auth+/reset-password.tsx b/app/routes/_auth+/reset-password.tsx index 6ffba25..0428bd6 100644 --- a/app/routes/_auth+/reset-password.tsx +++ b/app/routes/_auth+/reset-password.tsx @@ -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 { invariant } from '@epic-web/invariant' import { json, @@ -22,7 +22,10 @@ import { type VerifyFunctionArgs } from './verify.tsx' const resetPasswordUsernameSessionKey = 'resetPasswordUsername' export async function handleVerification({ submission }: VerifyFunctionArgs) { - invariant(submission.value, 'submission.value should be defined by now') + invariant( + submission.status === 'success', + 'Submission should be successful by now', + ) const target = submission.value.target const user = await prisma.user.findFirst({ where: { OR: [{ email: target }, { username: target }] }, @@ -31,8 +34,14 @@ export async function handleVerification({ submission }: VerifyFunctionArgs) { // we don't want to say the user is not found if the email is not found // because that would allow an attacker to check if an email is registered if (!user) { - submission.error.code = ['Invalid code'] - return json({ status: 'error', submission } as const, { status: 400 }) + return json( + { + result: submission.reply({ fieldErrors: { code: ['Invalid code'] } }), + }, + { + status: 400, + }, + ) } const verifySession = await verifySessionStorage.getSession() @@ -68,14 +77,14 @@ export async function loader({ request }: LoaderFunctionArgs) { export async function action({ request }: ActionFunctionArgs) { const resetPasswordUsername = await requireResetPasswordUsername(request) const formData = await request.formData() - const submission = parse(formData, { + const submission = parseWithZod(formData, { schema: ResetPasswordSchema, }) - if (submission.intent !== 'submit') { - return json({ status: 'idle', submission } as const) - } - if (!submission.value?.password) { - 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 { password } = submission.value @@ -99,10 +108,10 @@ export default function ResetPasswordPage() { const [form, fields] = useForm({ id: 'reset-password', - constraint: getFieldsetConstraint(ResetPasswordSchema), - lastSubmission: actionData?.submission, + constraint: getZodConstraint(ResetPasswordSchema), + lastResult: actionData?.result, onValidate({ formData }) { - return parse(formData, { schema: ResetPasswordSchema }) + return parseWithZod(formData, { schema: ResetPasswordSchema }) }, shouldRevalidate: 'onBlur', }) @@ -116,14 +125,14 @@ export default function ResetPasswordPage() {

- + diff --git a/app/routes/_auth+/signup.tsx b/app/routes/_auth+/signup.tsx index 90a0b8f..0303877 100644 --- a/app/routes/_auth+/signup.tsx +++ b/app/routes/_auth+/signup.tsx @@ -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, @@ -33,7 +33,7 @@ export async function action({ request }: ActionFunctionArgs) { checkHoneypot(formData) - const submission = await parse(formData, { + const submission = await parseWithZod(formData, { schema: SignupSchema.superRefine(async (data, ctx) => { const existingUser = await prisma.user.findUnique({ where: { email: data.email }, @@ -50,11 +50,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 { email } = submission.value const { verifyUrl, redirectTo, otp } = await prepareVerification({ @@ -73,8 +73,14 @@ 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, + }, + ) } } @@ -117,10 +123,10 @@ export default function SignupRoute() { const [form, fields] = useForm({ id: 'signup-form', - constraint: getFieldsetConstraint(SignupSchema), - lastSubmission: actionData?.submission, + constraint: getZodConstraint(SignupSchema), + lastResult: actionData?.result, onValidate({ formData }) { - const result = parse(formData, { schema: SignupSchema }) + const result = parseWithZod(formData, { schema: SignupSchema }) return result }, shouldRevalidate: 'onBlur', @@ -135,7 +141,7 @@ export default function SignupRoute() {

- + diff --git a/app/routes/_auth+/verify.tsx b/app/routes/_auth+/verify.tsx index 8566676..0612822 100644 --- a/app/routes/_auth+/verify.tsx +++ b/app/routes/_auth+/verify.tsx @@ -1,5 +1,10 @@ -import { conform, useForm, type Submission } from '@conform-to/react' -import { getFieldsetConstraint, parse } from '@conform-to/zod' +import { + useForm, + type Submission, + getFormProps, + getInputProps, +} from '@conform-to/react' +import { getZodConstraint, parseWithZod } from '@conform-to/zod' import { json, type ActionFunctionArgs } from '@remix-run/node' import { Form, useActionData, useSearchParams } from '@remix-run/react' import { HoneypotInputs } from 'remix-utils/honeypot/react' @@ -124,7 +129,11 @@ export async function prepareVerification({ export type VerifyFunctionArgs = { request: Request - submission: Submission> + submission: Submission< + z.input, + string[], + z.output + > body: FormData | URLSearchParams } @@ -158,7 +167,7 @@ async function validateRequest( request: Request, body: URLSearchParams | FormData, ) { - const submission = await parse(body, { + const submission = await parseWithZod(body, { schema: VerifySchema.superRefine(async (data, ctx) => { const codeIsValid = await isCodeValid({ code: data[codeQueryParam], @@ -177,11 +186,11 @@ async function validateRequest( 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 }, + ) } // this code path could be part of a loader (GET request), so we need to make @@ -224,10 +233,10 @@ export default function VerifyRoute() { const [searchParams] = useSearchParams() const isPending = useIsPending() const actionData = useActionData() - const parsedType = VerificationTypeSchema.safeParse( + const parseWithZoddType = VerificationTypeSchema.safeParse( searchParams.get(typeQueryParam), ) - const type = parsedType.success ? parsedType.data : null + const type = parseWithZoddType.success ? parseWithZoddType.data : null const checkEmail = ( <> @@ -254,16 +263,16 @@ export default function VerifyRoute() { const [form, fields] = useForm({ id: 'verify-form', - constraint: getFieldsetConstraint(VerifySchema), - lastSubmission: actionData?.submission, + constraint: getZodConstraint(VerifySchema), + lastResult: actionData?.result, onValidate({ formData }) { - return parse(formData, { schema: VerifySchema }) + return parseWithZod(formData, { schema: VerifySchema }) }, defaultValue: { - code: searchParams.get(codeQueryParam) ?? '', - type, - target: searchParams.get(targetQueryParam) ?? '', - redirectTo: searchParams.get(redirectToQueryParam) ?? '', + code: searchParams.get(codeQueryParam), + type: type, + target: searchParams.get(targetQueryParam), + redirectTo: searchParams.get(redirectToQueryParam), }, }) @@ -280,7 +289,7 @@ export default function VerifyRoute() {
- + diff --git a/app/routes/_marketing+/index.tsx b/app/routes/_marketing+/index.tsx index e7b2798..f25d1bb 100644 --- a/app/routes/_marketing+/index.tsx +++ b/app/routes/_marketing+/index.tsx @@ -1,28 +1,13 @@ -import { - type LoaderFunctionArgs, - redirect, - type MetaFunction, -} from '@remix-run/node' +import { type MetaFunction } from '@remix-run/node' import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger, } from '#app/components/ui/tooltip.tsx' -import { getUserId } from '#app/utils/auth.server.ts' import { cn } from '#app/utils/misc.tsx' import { logos } from './logos/logos.ts' -export async function loader({ request }: LoaderFunctionArgs) { - const userId = await getUserId(request) - - if (userId) { - return redirect('/studio') - } - - return null -} - export const meta: MetaFunction = () => [{ title: 'Epic Notes' }] // Tailwind Grid cell classes lookup @@ -45,7 +30,7 @@ const rowClasses: Record<(typeof logos)[number]['row'], string> = { export default function Index() { return (
-
+
null, diff --git a/app/routes/admin+/cache_.lru.$cacheKey.ts b/app/routes/admin+/cache_.lru.$cacheKey.ts index 2a7bbff..5083fd9 100644 --- a/app/routes/admin+/cache_.lru.$cacheKey.ts +++ b/app/routes/admin+/cache_.lru.$cacheKey.ts @@ -3,7 +3,7 @@ import { json, type LoaderFunctionArgs } from '@remix-run/node' import { getAllInstances, getInstanceInfo } from 'litefs-js' import { ensureInstance } from 'litefs-js/remix.js' import { lruCache } from '#app/utils/cache.server.ts' -import { requireUserWithRole } from '#app/utils/permissions.server' +import { requireUserWithRole } from '#app/utils/permissions.server.ts' export async function loader({ request, params }: LoaderFunctionArgs) { await requireUserWithRole(request, 'admin') diff --git a/app/routes/admin+/cache_.sqlite.$cacheKey.ts b/app/routes/admin+/cache_.sqlite.$cacheKey.ts index cf87c0d..bd1ae37 100644 --- a/app/routes/admin+/cache_.sqlite.$cacheKey.ts +++ b/app/routes/admin+/cache_.sqlite.$cacheKey.ts @@ -3,7 +3,7 @@ import { json, type LoaderFunctionArgs } from '@remix-run/node' import { getAllInstances, getInstanceInfo } from 'litefs-js' import { ensureInstance } from 'litefs-js/remix.js' import { cache } from '#app/utils/cache.server.ts' -import { requireUserWithRole } from '#app/utils/permissions.server' +import { requireUserWithRole } from '#app/utils/permissions.server.ts' export async function loader({ request, params }: LoaderFunctionArgs) { await requireUserWithRole(request, 'admin') diff --git a/app/routes/resources+/download-user-data.tsx b/app/routes/resources+/download-user-data.tsx index 307d41d..a3d12e2 100644 --- a/app/routes/resources+/download-user-data.tsx +++ b/app/routes/resources+/download-user-data.tsx @@ -48,7 +48,7 @@ export async function loader({ request }: LoaderFunctionArgs) { ? { ...user.image, url: `${domain}/resources/user-images/${user.image.id}`, - } + } : null, notes: user.notes.map(note => ({ ...note, diff --git a/app/routes/settings+/profile.change-email.tsx b/app/routes/settings+/profile.change-email.tsx index 9808d81..03db9f4 100644 --- a/app/routes/settings+/profile.change-email.tsx +++ b/app/routes/settings+/profile.change-email.tsx @@ -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 { invariant } from '@epic-web/invariant' import { type SEOHandle } from '@nasa-gcn/remix-seo' import * as E from '@react-email/components' @@ -40,17 +40,26 @@ export async function handleVerification({ submission, }: VerifyFunctionArgs) { await requireRecentVerification(request) - invariant(submission.value, 'submission.value should be defined by now') + invariant( + submission.status === 'success', + 'Submission should be successful by now', + ) const verifySession = await verifySessionStorage.getSession( request.headers.get('cookie'), ) const newEmail = verifySession.get(newEmailAddressSessionKey) if (!newEmail) { - submission.error[''] = [ - 'You must submit the code on the same device that requested the email change.', - ] - return json({ status: 'error', submission } as const, { status: 400 }) + return json( + { + result: submission.reply({ + formErrors: [ + 'You must submit the code on the same device that requested the email change.', + ], + }), + }, + { status: 400 }, + ) } const preUpdateUser = await prisma.user.findFirstOrThrow({ select: { email: true }, @@ -104,7 +113,7 @@ export async function loader({ request }: LoaderFunctionArgs) { export async function action({ request }: ActionFunctionArgs) { const userId = await requireUserId(request) const formData = await request.formData() - const submission = await parse(formData, { + const submission = await parseWithZod(formData, { schema: ChangeEmailSchema.superRefine(async (data, ctx) => { const existingUser = await prisma.user.findUnique({ where: { email: data.email }, @@ -120,11 +129,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 { otp, redirectTo, verifyUrl } = await prepareVerification({ period: 10 * 60, @@ -148,8 +157,14 @@ export async function action({ request }: ActionFunctionArgs) { }, }) } 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, + }, + ) } } @@ -214,10 +229,10 @@ export default function ChangeEmailIndex() { const [form, fields] = useForm({ id: 'change-email-form', - constraint: getFieldsetConstraint(ChangeEmailSchema), - lastSubmission: actionData?.submission, + constraint: getZodConstraint(ChangeEmailSchema), + lastResult: actionData?.result, onValidate({ formData }) { - return parse(formData, { schema: ChangeEmailSchema }) + return parseWithZod(formData, { schema: ChangeEmailSchema }) }, }) @@ -230,11 +245,11 @@ export default function ChangeEmailIndex() { An email notice will also be sent to your old address {data.user.email}.

- +
Send Confirmation diff --git a/app/routes/settings+/profile.index.tsx b/app/routes/settings+/profile.index.tsx index 03c4467..1d3b2af 100644 --- a/app/routes/settings+/profile.index.tsx +++ b/app/routes/settings+/profile.index.tsx @@ -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 { invariantResponse } from '@epic-web/invariant' import { type SEOHandle } from '@nasa-gcn/remix-seo' import { @@ -177,7 +177,7 @@ export default function EditUserProfile() { } async function profileUpdateAction({ userId, formData }: ProfileActionArgs) { - const submission = await parse(formData, { + const submission = await parseWithZod(formData, { async: true, schema: ProfileFormSchema.superRefine(async ({ username }, ctx) => { const existingUsername = await prisma.user.findUnique({ @@ -193,11 +193,11 @@ async function profileUpdateAction({ userId, formData }: ProfileActionArgs) { } }), }) - 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 data = submission.value @@ -211,7 +211,9 @@ async function profileUpdateAction({ userId, formData }: ProfileActionArgs) { }, }) - return json({ status: 'success', submission } as const) + return json({ + result: submission.reply(), + }) } function UpdateProfile() { @@ -221,20 +223,19 @@ function UpdateProfile() { const [form, fields] = useForm({ id: 'edit-profile', - constraint: getFieldsetConstraint(ProfileFormSchema), - lastSubmission: fetcher.data?.submission, + constraint: getZodConstraint(ProfileFormSchema), + lastResult: fetcher.data?.result, onValidate({ formData }) { - return parse(formData, { schema: ProfileFormSchema }) + return parseWithZod(formData, { schema: ProfileFormSchema }) }, defaultValue: { username: data.user.username, - name: data.user.name ?? '', - email: data.user.email, + name: data.user.name, }, }) return ( - +
@@ -261,11 +262,7 @@ function UpdateProfile() { size="wide" name="intent" value={profileUpdateActionIntent} - status={ - fetcher.state !== 'idle' - ? 'pending' - : fetcher.data?.status ?? 'idle' - } + status={fetcher.state !== 'idle' ? 'pending' : form.status ?? 'idle'} > Save changes diff --git a/app/routes/settings+/profile.password.tsx b/app/routes/settings+/profile.password.tsx index 097a472..50f3f44 100644 --- a/app/routes/settings+/profile.password.tsx +++ b/app/routes/settings+/profile.password.tsx @@ -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 { type SEOHandle } from '@nasa-gcn/remix-seo' import { json, @@ -65,7 +65,7 @@ export async function action({ request }: ActionFunctionArgs) { const userId = await requireUserId(request) await requirePassword(userId) const formData = await request.formData() - const submission = await parse(formData, { + const submission = await parseWithZod(formData, { async: true, schema: ChangePasswordForm.superRefine( async ({ currentPassword, newPassword }, ctx) => { @@ -82,15 +82,15 @@ export async function action({ request }: ActionFunctionArgs) { }, ), }) - // clear the payload so we don't send the password back to the client - submission.payload = {} - if (submission.intent !== 'submit') { - // clear the value so we don't send the password back to the client - submission.value = undefined - 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({ + hideFields: ['currentPassword', 'newPassword', 'confirmNewPassword'], + }), + }, + { status: submission.status === 'error' ? 400 : 200 }, + ) } const { newPassword } = submission.value @@ -124,20 +124,20 @@ export default function ChangePasswordRoute() { const [form, fields] = useForm({ id: 'password-change-form', - constraint: getFieldsetConstraint(ChangePasswordForm), - lastSubmission: actionData?.submission, + constraint: getZodConstraint(ChangePasswordForm), + lastResult: actionData?.result, onValidate({ formData }) { - return parse(formData, { schema: ChangePasswordForm }) + return parseWithZod(formData, { schema: ChangePasswordForm }) }, shouldRevalidate: 'onBlur', }) return ( - + Change Password diff --git a/app/routes/settings+/profile.password_.create.tsx b/app/routes/settings+/profile.password_.create.tsx index 26c65d6..b4365dc 100644 --- a/app/routes/settings+/profile.password_.create.tsx +++ b/app/routes/settings+/profile.password_.create.tsx @@ -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 { type SEOHandle } from '@nasa-gcn/remix-seo' import { json, @@ -45,19 +45,19 @@ export async function action({ request }: ActionFunctionArgs) { const userId = await requireUserId(request) await requireNoPassword(userId) const formData = await request.formData() - const submission = await parse(formData, { + const submission = await parseWithZod(formData, { async: true, schema: CreatePasswordForm, }) - // clear the payload so we don't send the password back to the client - submission.payload = {} - if (submission.intent !== 'submit') { - // clear the value so we don't send the password back to the client - submission.value = undefined - 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({ + hideFields: ['password', 'confirmPassword'], + }), + }, + { status: submission.status === 'error' ? 400 : 200 }, + ) } const { password } = submission.value @@ -83,20 +83,20 @@ export default function CreatePasswordRoute() { const [form, fields] = useForm({ id: 'password-create-form', - constraint: getFieldsetConstraint(CreatePasswordForm), - lastSubmission: actionData?.submission, + constraint: getZodConstraint(CreatePasswordForm), + lastResult: actionData?.result, onValidate({ formData }) { - return parse(formData, { schema: CreatePasswordForm }) + return parseWithZod(formData, { schema: CreatePasswordForm }) }, shouldRevalidate: 'onBlur', }) return ( - + Create Password diff --git a/app/routes/settings+/profile.photo.tsx b/app/routes/settings+/profile.photo.tsx index a4042d1..72897b1 100644 --- a/app/routes/settings+/profile.photo.tsx +++ b/app/routes/settings+/profile.photo.tsx @@ -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 { invariantResponse } from '@epic-web/invariant' import { type SEOHandle } from '@nasa-gcn/remix-seo' import { @@ -50,7 +50,10 @@ const NewImageSchema = z.object({ .refine(file => file.size <= MAX_SIZE, 'Image size must be less than 3MB'), }) -const PhotoFormSchema = z.union([DeleteImageSchema, NewImageSchema]) +const PhotoFormSchema = z.discriminatedUnion('intent', [ + DeleteImageSchema, + NewImageSchema, +]) export async function loader({ request }: LoaderFunctionArgs) { const userId = await requireUserId(request) @@ -74,7 +77,7 @@ export async function action({ request }: ActionFunctionArgs) { unstable_createMemoryUploadHandler({ maxPartSize: MAX_SIZE }), ) - const submission = await parse(formData, { + const submission = await parseWithZod(formData, { schema: PhotoFormSchema.transform(async data => { if (data.intent === 'delete') return { intent: 'delete' } if (data.photoFile.size <= 0) return z.NEVER @@ -89,11 +92,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 { image, intent } = submission.value @@ -124,22 +127,17 @@ export default function PhotoRoute() { const [form, fields] = useForm({ id: 'profile-photo', - constraint: getFieldsetConstraint(PhotoFormSchema), - lastSubmission: actionData?.submission, + constraint: getZodConstraint(PhotoFormSchema), + lastResult: actionData?.result, onValidate({ formData }) { - // otherwise, the best error zod gives us is "Invalid input" which is not - // enough - if (formData.get('intent') === 'delete') { - return parse(formData, { schema: DeleteImageSchema }) - } - return parse(formData, { schema: NewImageSchema }) + return parseWithZod(formData, { schema: PhotoFormSchema }) }, shouldRevalidate: 'onBlur', }) const isPending = useIsPending() const pendingIntent = isPending ? navigation.formData?.get('intent') : null - const lastSubmissionIntent = actionData?.submission.value?.intent + const lastSubmissionIntent = fields.intent.value const [newImageSrc, setNewImageSrc] = useState(null) @@ -150,7 +148,7 @@ export default function PhotoRoute() { encType="multipart/form-data" className="flex flex-col items-center justify-center gap-10" onReset={() => setNewImageSrc(null)} - {...form.props} + {...getFormProps(form)} > Save Photo @@ -227,8 +225,8 @@ export default function PhotoRoute() { pendingIntent === 'delete' ? 'pending' : lastSubmissionIntent === 'delete' - ? actionData?.status ?? 'idle' - : 'idle' + ? form.status ?? 'idle' + : 'idle' } > diff --git a/app/routes/settings+/profile.two-factor.verify.tsx b/app/routes/settings+/profile.two-factor.verify.tsx index cf72e9f..ba3adf9 100644 --- a/app/routes/settings+/profile.two-factor.verify.tsx +++ b/app/routes/settings+/profile.two-factor.verify.tsx @@ -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 { type SEOHandle } from '@nasa-gcn/remix-seo' import { json, @@ -38,7 +38,10 @@ const VerifySchema = z.object({ code: z.string().min(6).max(6), }) -const ActionSchema = z.union([CancelSchema, VerifySchema]) +const ActionSchema = z.discriminatedUnion('intent', [ + CancelSchema, + VerifySchema, +]) export const twoFAVerifyVerificationType = '2fa-verify' @@ -77,7 +80,7 @@ export async function action({ request }: ActionFunctionArgs) { const userId = await requireUserId(request) const formData = await request.formData() - const submission = await parse(formData, { + const submission = await parseWithZod(formData, { schema: () => ActionSchema.superRefine(async (data, ctx) => { if (data.intent === 'cancel') return null @@ -98,11 +101,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 }, + ) } switch (submission.value.intent) { @@ -135,21 +138,16 @@ export default function TwoFactorRoute() { const isPending = useIsPending() const pendingIntent = isPending ? navigation.formData?.get('intent') : null - const lastSubmissionIntent = actionData?.submission.value?.intent const [form, fields] = useForm({ id: 'verify-form', - constraint: getFieldsetConstraint(ActionSchema), - lastSubmission: actionData?.submission, + constraint: getZodConstraint(ActionSchema), + lastResult: actionData?.result, onValidate({ formData }) { - // otherwise, the best error zod gives us is "Invalid input" which is not - // enough - if (formData.get('intent') === 'cancel') { - return parse(formData, { schema: CancelSchema }) - } - return parse(formData, { schema: VerifySchema }) + return parseWithZod(formData, { schema: ActionSchema }) }, }) + const lastSubmissionIntent = fields.intent.value return (
@@ -176,14 +174,14 @@ export default function TwoFactorRoute() { lose access to your account.

- + { if (!data.id) return @@ -130,12 +131,11 @@ export async function action({ request }: ActionFunctionArgs) { async: true, }) - if (submission.intent !== 'submit') { - return json({ submission } as const) - } - - if (!submission.value) { - return json({ submission } as const, { status: 400 }) + if (submission.status !== 'success') { + return json( + { result: submission.reply() }, + { status: submission.status === 'error' ? 400 : 200 }, + ) } const { @@ -188,120 +188,121 @@ export function NoteEditor({ const [form, fields] = useForm({ id: 'note-editor', - constraint: getFieldsetConstraint(NoteEditorSchema), - lastSubmission: actionData?.submission, + constraint: getZodConstraint(NoteEditorSchema), + lastResult: actionData?.result, onValidate({ formData }) { - return parse(formData, { schema: NoteEditorSchema }) + return parseWithZod(formData, { schema: NoteEditorSchema }) }, defaultValue: { - title: note?.title ?? '', - content: note?.content ?? '', + ...note, images: note?.images ?? [{}], }, + shouldRevalidate: 'onBlur', }) - const imageList = useFieldList(form.ref, fields.images) + const imageList = fields.images.getFieldList() return (
- - {/* + + + {/* This hidden submit button is here to ensure that when the user hits "enter" on an input field, the primary form function is submitted rather than the first button in the form (which is delete/add image). */} - - - - ))} - + + + + ) + })} + +
+
- + + Submit +
- - -
- - - Submit - -
+
) } -function ImageChooser({ - config, -}: { - config: FieldConfig> -}) { - const ref = useRef(null) - const fields = useFieldset(ref, config) - const existingImage = Boolean(fields.id.defaultValue) +function ImageChooser({ meta }: { meta: FieldMetadata }) { + const fields = meta.getFieldset() + const existingImage = Boolean(fields.id.initialValue) const [previewImage, setPreviewImage] = useState( - fields.id.defaultValue ? getNoteImgSrc(fields.id.defaultValue) : null, + fields.id.initialValue ? getNoteImgSrc(fields.id.initialValue) : null, ) - const [altText, setAltText] = useState(fields.altText.defaultValue ?? '') + const [altText, setAltText] = useState(fields.altText.initialValue ?? '') return ( -
+
@@ -332,12 +333,7 @@ function ImageChooser({
)} {existingImage ? ( - + ) : null}
@@ -371,7 +364,7 @@ function ImageChooser({