Skip to content
This repository was archived by the owner on Feb 11, 2025. It is now read-only.

Commit

Permalink
feat: add experimental support for FormData mutations (trpc#3795)
Browse files Browse the repository at this point in the history
  • Loading branch information
sachinraja authored Apr 29, 2023
1 parent c8caca9 commit 0aaf147
Show file tree
Hide file tree
Showing 61 changed files with 6,292 additions and 12,514 deletions.
3 changes: 1 addition & 2 deletions .eslintrc.js
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,7 @@ const config = {
sourceType: 'module', // Allows for the use of import
tsconfigRootDir: __dirname,
project: [
'./examples/.interop/*/tsconfig.json',
'./examples/.test/*/tsconfig.json',
'./examples/.*/*/tsconfig.json',
'./examples/*/tsconfig.json',
'./packages/*/tsconfig.json',
'./tsconfig.json',
Expand Down
1 change: 1 addition & 0 deletions .github/workflows/examples.yml
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ jobs:
.test/big-router-declaration,
soa,
vercel-edge-runtime,
./experimental/next-formdata,
]
node-start: ['18.x']
os: [ubuntu-latest]
Expand Down
1 change: 1 addition & 0 deletions .npmrc
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
public-hoist-pattern[]=@types/*
public-hoist-pattern[]=playwright-core
public-hoist-pattern[]=zod
side-effects-cache=false
strict-peer-dependencies=false
2 changes: 1 addition & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@ This package contains the core and server-side functionality. If something is sh

#### Building a Router

This is where tRPC has the most interaction with users, so it should be treated with a great deal of importance. We care about offering a simple, intuitive API in which the HTTP layer disappears for the user. Here the user's entry point is [`initTRPC`](packages/server/src/core/initTRPC.ts) where root configuration such as a [data transformer](https://trpc.io/docs/data-transformers) is set and factory functions for router, procedure, middleware, etc. creation are returned.
This is where tRPC has the most interaction with users, so it should be treated with a great deal of importance. We care about offering a simple, intuitive API in which the HTTP layer disappears for the user. Here the user's entrypoint is [`initTRPC`](packages/server/src/core/initTRPC.ts) where root configuration such as a [data transformer](https://trpc.io/docs/data-transformers) is set and factory functions for router, procedure, middleware, etc. creation are returned.

The most complex types are also in this area because we must keep track of the context, meta, middleware, and each procedure and its inputs and outputs. If you are ever struggling to understand a type, feel free to ask for help on [Discord](https://trpc.io/discord).

Expand Down
1 change: 1 addition & 0 deletions codecov.yml
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ comment: false # do not comment PR with the result

ignore:
- '**/deprecated/**'
- '**/form-data/**'
coverage:
range: 50..90 # coverage lower than 50 is red, higher than 90 green, between color code

Expand Down
1 change: 1 addition & 0 deletions examples/.experimental/next-formdata/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
public/uploads
28 changes: 28 additions & 0 deletions examples/.experimental/next-formdata/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
# Next.js + tRPC + `FormData`

> 🚧🚧 🚧 🚧 🚧 🚧 🚧 🚧 🚧 This is experimental 🚧 🚧 🚧 🚧 🚧 🚧 🚧 🚧 🚧
## Setup

```bash
npx create-next-app --example https://github.com/trpc/trpc --example-path examples/.experimental/next-formdata trpc-formdata
cd trpc-formdata
npm i
npm run dev
```

### Adding FormData to your project

1. Add support for FormData & JSON content types to your HTTP handler:

- https://github.com/trpc/trpc/blob/5d234b6d26173256caa16045cf3ba931399c2629/examples/.experimental/next-formdata/src/pages/api/trpc/%5Btrpc%5D.ts#L6-L7
- https://github.com/trpc/trpc/blob/5d234b6d26173256caa16045cf3ba931399c2629/examples/.experimental/next-formdata/src/pages/api/trpc/%5Btrpc%5D.ts#L24-L27

1. Make sure you return `req` in your `createContext()`-fn: https://github.com/trpc/trpc/blob/524b0c866b55cfc1ddd0f89885b7a5f26dde288a/examples/.experimental/next-formdata/src/server/trpc.ts#L20
1. Add a middleware where you want to use `FormData`: https://github.com/trpc/trpc/blob/1b29a7425ef784c59b2c5e3bc1229713671508d6/examples/.experimental/next-formdata/src/server/routers/room.ts#L11-L21
1. Add a `httpFormDataLink`: https://github.com/trpc/trpc/blob/74bb462ca9ba91e8e077f5abf655c792b87f6995/examples/.experimental/next-formdata/src/utils/trpc.ts#L57-L65
1. Create a validation schema using `FormData`: https://github.com/trpc/trpc/blob/d1b3d5b53ff54af5d00ab99052ecf23a84277635/examples/.experimental/next-formdata/src/utils/schemas.ts#L1-L11
1. Write a backend procedure that uses the form data: https://github.com/trpc/trpc/blob/d1b3d5b53ff54af5d00ab99052ecf23a84277635/examples/.experimental/next-formdata/src/server/routers/room.ts#L25-L31
1. Create a form, see examples:
- [./src/pages/vanilla.tsx](./src/pages/vanilla.tsx)
- [./src/pages/react-hook-form.tsx](./src/pages/react-hook-form.tsx)
5 changes: 5 additions & 0 deletions examples/.experimental/next-formdata/next-env.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />

// NOTE: This file should not be edited
// see https://nextjs.org/docs/basic-features/typescript for more information.
5 changes: 5 additions & 0 deletions examples/.experimental/next-formdata/next.config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
/** @type {import("next").NextConfig} */
module.exports = {
/** We run eslint as a separate task in CI */
eslint: { ignoreDuringBuilds: !!process.env.CI },
};
34 changes: 34 additions & 0 deletions examples/.experimental/next-formdata/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
{
"name": "@examples/next-formdata",
"version": "10.21.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"lint": "eslint --ext \".js,.ts,.tsx\" --report-unused-disable-directives src",
"start": "next start"
},
"dependencies": {
"@hookform/error-message": "^2.0.1",
"@hookform/resolvers": "^2.9.11",
"@tanstack/react-query": "^4.18.0",
"@trpc/client": "10.22.0",
"@trpc/next": "10.22.0",
"@trpc/react-query": "10.22.0",
"@trpc/server": "10.22.0",
"next": "^13.2.1",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-hook-form": "^7.43.3",
"undici": "^5.14.0",
"zod": "^3.0.0",
"zod-form-data": "^2.0.1"
},
"devDependencies": {
"@types/node": "^18.7.20",
"@types/react": "^18.0.9",
"@types/react-dom": "^18.0.5",
"eslint": "^8.30.0",
"typescript": "^4.8.3"
}
}
8 changes: 8 additions & 0 deletions examples/.experimental/next-formdata/src/pages/_app.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import type { AppType } from 'next/app';
import { trpc } from '../utils/trpc';

const MyApp: AppType = ({ Component, pageProps }) => {
return <Component {...pageProps} />;
};

export default trpc.withTRPC(MyApp);
40 changes: 40 additions & 0 deletions examples/.experimental/next-formdata/src/pages/api/trpc/[trpc].ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
/**
* This is the API-handler of your app that contains all your API routes.
* On a bigger app, you will probably want to split this file up into multiple files.
*/
import * as trpcNext from '@trpc/server/adapters/next';
import { nodeHTTPFormDataContentTypeHandler } from '@trpc/server/adapters/node-http/content-type/form-data';
import { nodeHTTPJSONContentTypeHandler } from '@trpc/server/adapters/node-http/content-type/json';
import { NextApiRequest, NextApiResponse } from 'next';
import * as undici from 'undici';
import { roomRouter } from '~/server/routers/room';
import { createContext, router } from '~/server/trpc';

const appRouter = router({
room: roomRouter,
});

// export only the type definition of the API
// None of the actual implementation is exposed to the client
export type AppRouter = typeof appRouter;

const handler = trpcNext.createNextApiHandler({
router: appRouter,
createContext,
experimental_contentTypeHandlers: [
nodeHTTPFormDataContentTypeHandler(),
nodeHTTPJSONContentTypeHandler(),
],
});

// export API handler
export default async (req: NextApiRequest, res: NextApiResponse) => {
await handler(req, res);
};

export const config = {
api: {
bodyParser: false,
responseLimit: '100mb',
},
};
14 changes: 14 additions & 0 deletions examples/.experimental/next-formdata/src/pages/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import Link from 'next/link';

export default function IndexPage() {
return (
<ul>
<li>
<Link href="/vanilla">/vanilla</Link>
</li>
<li>
<Link href="/react-hook-form">/react-hook-form</Link>
</li>
</ul>
);
}
139 changes: 139 additions & 0 deletions examples/.experimental/next-formdata/src/pages/react-hook-form.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
import { zodResolver } from '@hookform/resolvers/zod';
import { useRef, useState } from 'react';
import { FormProvider, UseFormProps, useForm } from 'react-hook-form';
import { z } from 'zod';
import { uploadFileSchema } from '~/utils/schemas';
import { trpc } from '~/utils/trpc';

/**
* zod-form-data wraps zod in an effect where the original type is a `FormData`
*/
type UnwrapZodEffect<TType extends z.ZodType> = TType extends z.ZodEffects<
infer U,
any,
any
>
? U
: TType;

type GetInput<TType extends z.ZodType> = UnwrapZodEffect<TType>['_input'];

function useZodFormData<TSchema extends z.ZodType>(
props: {
schema: TSchema;
} & Omit<UseFormProps<GetInput<TSchema>>, 'resolver'>,
) {
const formRef = useRef<HTMLFormElement>(null);
const _resolver = zodResolver(props.schema, undefined, {
rawValues: true,
});

const form = useForm<GetInput<TSchema>>({
...props,
resolver: (_, ctx, opts) => {
if (!formRef.current) {
return {
values: {},
errors: {
root: {
message: 'Form not mounted',
},
},
};
}
const values = new FormData(formRef.current);
return _resolver(values, ctx, opts);
},
});

return { ...form, formRef };
}

export default function Page() {
const mutation = trpc.room.sendMessage.useMutation({
onError(err) {
alert('Error from server: ' + err.message);
},
});

const form = useZodFormData({
schema: uploadFileSchema,
defaultValues: {
name: 'whadaaaap',
},
});

const [noJs, setNoJs] = useState(false);

return (
<>
<h2 className="text-3xl font-bold">Posts</h2>

<FormProvider {...form}>
<form
method="post"
action={`/api/trpc/${mutation.trpc.path}`}
encType="multipart/form-data"
onSubmit={(_event) => {
if (noJs) {
return;
}
void form.handleSubmit(async (values, event) => {
await mutation.mutateAsync(new FormData(event?.target));
})(_event);
}}
style={{ display: 'flex', flexDirection: 'column', gap: 10 }}
ref={form.formRef}
>
<fieldset>
<legend>Form with file upload</legend>
<div style={{}}>
<label htmlFor="name">Enter your name</label>
<input {...form.register('name')} />
{form.formState.errors.name && (
<div>{form.formState.errors.name.message}</div>
)}
</div>

<div>
<label>Required file, only images</label>
<input type="file" {...form.register('image')} />
{form.formState.errors.image && (
<div>{form.formState.errors.image.message}</div>
)}
</div>

<div>
<label>Post without JS</label>
<input
type="checkbox"
checked={noJs}
onChange={(e) => setNoJs(e.target.checked)}
/>
</div>
<div>
<button type="submit" disabled={mutation.status === 'loading'}>
submit
</button>
</div>
</fieldset>
</form>

{mutation.data && (
<fieldset>
<legend>Upload result</legend>
<ul>
<li>
Image: <br />
<img
src={mutation.data.image.url}
alt={mutation.data.image.url}
/>
</li>
</ul>
</fieldset>
)}
</FormProvider>
</>
);
}
Loading

0 comments on commit 0aaf147

Please sign in to comment.