Skip to content

Commit

Permalink
1.0.0
Browse files Browse the repository at this point in the history
  • Loading branch information
kylekz committed Feb 23, 2024
2 parents e52e585 + 9b731a2 commit 367cda1
Show file tree
Hide file tree
Showing 22 changed files with 1,242 additions and 698 deletions.
4 changes: 2 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,8 @@ jobs:

strategy:
matrix:
os: [ubuntu-latest, windows-latest]
node-version: [14.x]
os: [ubuntu-latest]
node-version: [18.x, 20.x]

runs-on: ${{ matrix.os }}

Expand Down
3 changes: 0 additions & 3 deletions .vscode/settings.json

This file was deleted.

2 changes: 1 addition & 1 deletion LICENSE → LICENSE.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
The MIT License (MIT)

Copyright © 2021 EGOIST (https://github.com/sponsors/egoist)
Copyright © 2021 Reflex (https://github.com/teamreflex)

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
Expand Down
94 changes: 75 additions & 19 deletions README.md
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 &copy; [EGOIST](https://github.com/sponsors/egoist)
MIT &copy; [Reflex](https://github.com/teamreflex)
24 changes: 24 additions & 0 deletions examples/01-useFormState/actions.ts
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!",
}
},
})
}
32 changes: 32 additions & 0 deletions examples/01-useFormState/component.tsx
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>
)
}
26 changes: 26 additions & 0 deletions examples/02-nextjs-redirect/actions.ts
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}`)
},
})
}
25 changes: 25 additions & 0 deletions examples/02-nextjs-redirect/component.tsx
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>
)
}
36 changes: 36 additions & 0 deletions examples/03-custom-errors/actions.ts
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}`)
},
})
}
28 changes: 28 additions & 0 deletions examples/03-custom-errors/component.tsx
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>
)
}
31 changes: 31 additions & 0 deletions examples/04-helper-components/actions.ts
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}`)
},
})
}
37 changes: 37 additions & 0 deletions examples/04-helper-components/component.tsx
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>
)
}
Loading

0 comments on commit 367cda1

Please sign in to comment.