generated from egoist/ts-lib-starter
-
Notifications
You must be signed in to change notification settings - Fork 0
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
- Loading branch information
Showing
22 changed files
with
1,242 additions
and
698 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file was deleted.
Oops, something went wrong.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,35 +1,91 @@ | ||
**💛 You can help the author become a full-time open-source maintainer by [sponsoring him on GitHub](https://github.com/sponsors/egoist).** | ||
# @teamreflex/typed-action | ||
|
||
--- | ||
[![npm version](https://badgen.net/npm/v/@teamreflex/typed-action)](https://npm.im/@teamreflex/typed-action) [![npm downloads](https://badgen.net/npm/dm/@teamreflex/typed-action)](https://npm.im/@teamreflex/typed-action) | ||
|
||
# my-ts-lib | ||
Convenience wrapper for Zod validation in React server actions. | ||
|
||
[![npm version](https://badgen.net/npm/v/my-ts-lib)](https://npm.im/my-ts-lib) [![npm downloads](https://badgen.net/npm/dm/my-ts-lib)](https://npm.im/my-ts-lib) | ||
## Install | ||
|
||
## Using this template | ||
```bash | ||
npm i @teamreflex/typed-action | ||
``` | ||
|
||
- Search `my-ts-lib` and replace it with your custom package name. | ||
- Search `egoist` and replace it with your name. | ||
## Usage | ||
|
||
Features: | ||
Define a Zod schema for your form data: | ||
|
||
- Package manager [pnpm](https://pnpm.js.org/), safe and fast | ||
- Release with [semantic-release](https://npm.im/semantic-release) | ||
- Bundle with [tsup](https://github.com/egoist/tsup) | ||
- Test with [vitest](https://vitest.dev) | ||
```ts | ||
import { z } from "zod" | ||
|
||
To skip CI (GitHub action), add `skip-ci` to commit message. To skip release, add `skip-release` to commit message. | ||
const updateUserSchema = z.object({ | ||
name: z.string().min(3).max(64), | ||
email: z.string().email(), | ||
}) | ||
``` | ||
|
||
## Install | ||
Define a new action. This can be done as a const or function, if you wanted to mutate the form data before validation. | ||
|
||
```bash | ||
npm i my-ts-lib | ||
```ts | ||
"use server" | ||
import { typedAction } from "@teamreflex/typed-action" | ||
|
||
export const updateUser = async (form: FormData) => | ||
typedAction({ | ||
form, | ||
schema: updateUserSchema, | ||
onValidate: async ({ input }) => { | ||
// ^? { name: string, email: string } | ||
return await db.update(users).set(input).where({ id: 1 }) | ||
}, | ||
}) | ||
``` | ||
|
||
Then use it in your React components: | ||
|
||
```tsx | ||
import { updateUser } from "./actions" | ||
|
||
function UpdateUserForm() { | ||
return ( | ||
<form action={updateUser} className="flex flex-col gap-2"> | ||
<input type="text" name="name" /> | ||
<input type="email" name="email" /> | ||
<button type="submit">Update</button> | ||
</form> | ||
) | ||
} | ||
``` | ||
|
||
## Sponsors | ||
## `typedAction` Options | ||
|
||
### `form`: `FormData | Record<string, unknown>` | ||
|
||
Can be either a `FormData` or string-keyed object/Record. Objects allow for usage with `useTransition` usage of server actions, whereas `FormData` is more convenient for form submissions and required for `useFormState` usage. | ||
|
||
### `schema`: `ZodObject` | ||
|
||
Any Zod schema. | ||
|
||
### `onValidate`: `({ input: T }) => Promise<R>` | ||
|
||
An async function that executes upon a successful Zod validation. The input type `T` is inferred from the schema, and the return type `R` is inferred from the return type of the function. | ||
|
||
### `postValidate`: `(({ input: T, output: R }) => void) | undefined` | ||
|
||
An optional function that executes after the `onValidate` function. Because Nextjs's implementation of `redirect` and `notFound` results in throws, these can't be done in `onValidate` as they get caught. Instead, you can use `postValidate` to handle these cases. | ||
|
||
`T` is the Zod validation output/input to `onValidate`, and `R` is the output of `onValidate`. | ||
|
||
## Examples | ||
|
||
[![sponsors](https://sponsors-images.egoist.dev/sponsors.svg)](https://github.com/sponsors/egoist) | ||
| Link | Description | | ||
| ----------------------------------------------------- | ---------------------------------------------------------------- | | ||
| [01-useFormState](examples/01-useFormState) | Using React's `useFormState` hook to render success/error status | | ||
| [02-nextjs-redirect](examples/02-nextjs-redirect) | Perform a redirect using Next's `redirect` helper | | ||
| [03-custom-errors](examples/03-custom-errors) | Throw errors manually to seamlessly use the same state | | ||
| [04-helper-components](examples/04-helper-components) | Examples of helper components to make errors easier to render | | ||
| [05-useTransition](examples/05-useTransition) | Server actions don't always need to be forms | | ||
|
||
## License | ||
|
||
MIT © [EGOIST](https://github.com/sponsors/egoist) | ||
MIT © [Reflex](https://github.com/teamreflex) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,24 @@ | ||
import { typedAction } from "@teamreflex/typed-action" | ||
import { z } from "zod" | ||
|
||
const updateUserSchema = z.object({ | ||
name: z.string().min(3), | ||
email: z.string().email(), | ||
}) | ||
|
||
/** | ||
* Whatever `onValidate` returns, will be available in the `data` property of the output TypedActionResult object. | ||
*/ | ||
|
||
export async function updateUser(prev: unknown, form: FormData) { | ||
return typedAction({ | ||
form, | ||
schema: updateUserSchema, | ||
onValidate: async ({ input }) => { | ||
await db.update(users).set(input).where({ id: 1 }) | ||
return { | ||
message: "Your profile has been updated successfully!", | ||
} | ||
}, | ||
}) | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,32 @@ | ||
import { updateUser } from "./actions" | ||
import { useFormState, useFormStatus } from "react-dom" | ||
|
||
/** | ||
* `state` is a TypedActionResult object. | ||
* Use { status: "idle" } as initialState for useFormState. | ||
* This will change whenever the form is successful or throws an error. | ||
*/ | ||
|
||
export default function Component() { | ||
const [state, formAction] = useFormState(updateUser, { status: "idle" }) | ||
|
||
return ( | ||
<form action={formAction}> | ||
{state.status === "error" && <div>{state.error}</div>} | ||
{state.status === "success" && <div>{state.data.message}</div>} | ||
<input type="text" name="name" /> | ||
<input type="email" name="email" /> | ||
<Submit /> | ||
</form> | ||
) | ||
} | ||
|
||
function Submit() { | ||
const { pending } = useFormStatus() | ||
|
||
return ( | ||
<button type="submit" disabled={pending}> | ||
{pending ? "Submitting..." : "Submit"} | ||
</button> | ||
) | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,26 @@ | ||
import { typedAction } from "@teamreflex/typed-action" | ||
import { z } from "zod" | ||
import { redirect } from "next/navigation" | ||
|
||
const updateUserSchema = z.object({ | ||
name: z.string().min(3), | ||
email: z.string().email(), | ||
}) | ||
|
||
/** | ||
* Because the validated data and the output of `onValidate` are passed into `postValidate`, | ||
* you can use them to craft redirect URLs based on the result of the action. | ||
*/ | ||
|
||
export async function updateUser(prev: unknown, form: FormData) { | ||
return typedAction({ | ||
form, | ||
schema: updateUserSchema, | ||
onValidate: async ({ input }) => { | ||
await db.update(users).set(input).where({ id: 1 }) | ||
}, | ||
postValidate: ({ input, output }) => { | ||
redirect(`/profile/${output.id}`) | ||
}, | ||
}) | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,25 @@ | ||
import { updateUser } from "./actions" | ||
import { useFormState, useFormStatus } from "react-dom" | ||
|
||
export default function Component() { | ||
const [state, formAction] = useFormState(updateUser, { status: "idle" }) | ||
|
||
return ( | ||
<form action={formAction}> | ||
{state.status === "error" && <div>{state.error}</div>} | ||
<input type="text" name="name" /> | ||
<input type="email" name="email" /> | ||
<Submit /> | ||
</form> | ||
) | ||
} | ||
|
||
function Submit() { | ||
const { pending } = useFormStatus() | ||
|
||
return ( | ||
<button type="submit" disabled={pending}> | ||
{pending ? "Submitting..." : "Submit"} | ||
</button> | ||
) | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,36 @@ | ||
import { typedAction, ActionError } from "@teamreflex/typed-action" | ||
import { z } from "zod" | ||
import { redirect } from "next/navigation" | ||
|
||
const createTeamSchema = z.object({ | ||
name: z.string().min(3), | ||
}) | ||
|
||
/** | ||
* You might have some custom validation logic that you want to run on the server. | ||
* So you can throw an `ActionError` to return a custom error message to the client. | ||
*/ | ||
|
||
export async function createTeam(prev: unknown, form: FormData) { | ||
form.set("slug", slugify(form.get("name"))) | ||
|
||
return typedAction({ | ||
form, | ||
schema: createTeamSchema, | ||
onValidate: async ({ input }) => { | ||
if (slugIsTaken(input.slug)) { | ||
throw new ActionError({ | ||
result: "error", | ||
validationErrors: { | ||
slug: "This slug is already taken", | ||
}, | ||
}) | ||
} | ||
|
||
return await db.insert(teams).values(input) | ||
}, | ||
postValidate: ({ output }) => { | ||
redirect(`/team/${output.slug}`) | ||
}, | ||
}) | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,28 @@ | ||
import { createTeam } from "./actions" | ||
import { useState, useFormState, useFormStatus } from "react-dom" | ||
|
||
export default function Component() { | ||
const [state, formAction] = useFormState(createTeam, { status: "idle" }) | ||
const [slug, setSlug] = useState("") | ||
|
||
return ( | ||
<form action={formAction}> | ||
<input type="text" name="name" onChange=((e) => setSlug(slugify(e.currentTarget.value))) /> | ||
<input type="text" name="slug" value={slug} disabled readOnly /> | ||
{state.status === "error" && state.validationErrors.slug?.length && ( | ||
<div>{state.validationErrors.slug}</div> | ||
)} | ||
<Submit /> | ||
</form> | ||
) | ||
} | ||
|
||
function Submit() { | ||
const { pending } = useFormStatus() | ||
|
||
return ( | ||
<button type="submit" disabled={pending}> | ||
{pending ? "Submitting..." : "Submit"} | ||
</button> | ||
) | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,31 @@ | ||
import { typedAction, ActionError } from "@teamreflex/typed-action" | ||
import { z } from "zod" | ||
import { redirect } from "next/navigation" | ||
|
||
const createTeamSchema = z.object({ | ||
name: z.string().min(3), | ||
}) | ||
|
||
export async function createTeam(prev: unknown, form: FormData) { | ||
form.set("slug", slugify(form.get("name"))) | ||
|
||
return typedAction({ | ||
form, | ||
schema: createTeamSchema, | ||
onValidate: async ({ input }) => { | ||
if (slugIsTaken(input.slug)) { | ||
throw new ActionError({ | ||
result: "error", | ||
validationErrors: { | ||
slug: "This slug is already taken", | ||
}, | ||
}) | ||
} | ||
|
||
return await db.insert(teams).values(input) | ||
}, | ||
postValidate: ({ output }) => { | ||
redirect(`/team/${output.slug}`) | ||
}, | ||
}) | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,37 @@ | ||
import { createTeam } from "./actions" | ||
import { useFormState, useFormStatus } from "react-dom" | ||
import { FormError, FieldError } from "./form-helpers" | ||
|
||
/** | ||
* Instead of manually checking `state.status` every time for errors, | ||
* you can build your own helpers that abstract it all away. | ||
* FormError and FieldError examples can be found in ./form-helpers.tsx | ||
* This can easily be extended to complete <Form /> and <Field /> components if necessary. | ||
*/ | ||
|
||
export default function Component() { | ||
const [state, formAction] = useFormState(createTeam, { status: "idle" }) | ||
|
||
return ( | ||
<form action={formAction} className="flex flex-col gap-2"> | ||
<FormError state={state} className="py-2" /> | ||
|
||
<div className="flex flex-col"> | ||
<input type="text" name="name" /> | ||
<FieldError state={state} field="name" /> | ||
</div> | ||
|
||
<Submit /> | ||
</form> | ||
) | ||
} | ||
|
||
function Submit() { | ||
const { pending } = useFormStatus() | ||
|
||
return ( | ||
<button type="submit" disabled={pending}> | ||
{pending ? "Submitting..." : "Submit"} | ||
</button> | ||
) | ||
} |
Oops, something went wrong.