Skip to content

Commit

Permalink
feat: support passing schema via async function (#154)
Browse files Browse the repository at this point in the history
The code in this PR adds the support for passing a schema to the action via an async function inside the `schema` method. This is necessary, for instance, when you're using a i18n solution that requires to await the translations and pass them to schemas, as discussed in #111.
  • Loading branch information
TheEdoRan committed Jun 5, 2024
1 parent cd567a2 commit 4dcf742
Show file tree
Hide file tree
Showing 13 changed files with 163 additions and 26 deletions.
45 changes: 45 additions & 0 deletions apps/playground/src/app/(examples)/async-schema/login-action.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
"use server";

import { action } from "@/lib/safe-action";
import {
flattenValidationErrors,
returnValidationErrors,
} from "next-safe-action";
import { z } from "zod";

async function getSchema() {
return z.object({
username: z.string().min(3).max(10),
password: z.string().min(8).max(100),
});
}

export const loginUser = action
.metadata({ actionName: "loginUser" })
.schema(getSchema, {
// Here we use the `flattenValidationErrors` function to customize the returned validation errors
// object to the client.
handleValidationErrorsShape: (ve) =>
flattenValidationErrors(ve).fieldErrors,
})
.action(async ({ parsedInput: { username, password } }) => {
if (username === "johndoe") {
returnValidationErrors(getSchema, {
username: {
_errors: ["user_suspended"],
},
});
}

if (username === "user" && password === "password") {
return {
success: true,
};
}

returnValidationErrors(getSchema, {
username: {
_errors: ["incorrect_credentials"],
},
});
});
49 changes: 49 additions & 0 deletions apps/playground/src/app/(examples)/async-schema/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
"use client";

import { StyledButton } from "@/app/_components/styled-button";
import { StyledHeading } from "@/app/_components/styled-heading";
import { StyledInput } from "@/app/_components/styled-input";
import { useState } from "react";
import { ResultBox } from "../../_components/result-box";
import { loginUser } from "./login-action";

export default function DirectExamplePage() {
const [result, setResult] = useState<any>(undefined);

return (
<main className="w-96 max-w-full px-4">
<StyledHeading>
Action using direct call
<br />
(async schema)
</StyledHeading>
<form
className="flex flex-col mt-8 space-y-4"
onSubmit={async (e) => {
e.preventDefault();
const formData = new FormData(e.currentTarget);
const input = Object.fromEntries(formData) as {
username: string;
password: string;
};
const res = await loginUser(input); // this is the typesafe action directly called
setResult(res);
}}>
<StyledInput
type="text"
name="username"
id="username"
placeholder="Username"
/>
<StyledInput
type="password"
name="password"
id="password"
placeholder="Password"
/>
<StyledButton type="submit">Log in</StyledButton>
</form>
<ResultBox result={result} />
</main>
);
}
3 changes: 3 additions & 0 deletions apps/playground/src/app/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,9 @@ export default function Home() {
<h1 className="text-4xl font-semibold">Playground</h1>
<div className="mt-4 flex flex-col space-y-2">
<ExampleLink href="/direct">Direct call</ExampleLink>
<ExampleLink href="/async-schema">
Direct call (async schema)
</ExampleLink>
<ExampleLink href="/with-context">With Context</ExampleLink>
<ExampleLink href="/nested-schema">Nested schema</ExampleLink>
<ExampleLink href="/hook">
Expand Down
1 change: 1 addition & 0 deletions packages/next-safe-action/.eslintrc.js
Original file line number Diff line number Diff line change
Expand Up @@ -20,5 +20,6 @@ module.exports = defineConfig({
"@typescript-eslint/no-explicit-any": "off",
"@typescript-eslint/ban-types": "off",
"react-hooks/exhaustive-deps": "warn",
"@typescript-eslint/require-await": "off",
},
});
11 changes: 6 additions & 5 deletions packages/next-safe-action/src/action-builder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,12 +27,13 @@ export function actionBuilder<
MetadataSchema extends Schema | undefined = undefined,
MD = MetadataSchema extends Schema ? Infer<Schema> : undefined,
Ctx = undefined,
S extends Schema | undefined = undefined,
SF extends (() => Promise<Schema>) | undefined = undefined, // schema function
S extends Schema | undefined = SF extends Function ? Awaited<ReturnType<SF>> : undefined,
const BAS extends readonly Schema[] = [],
CVE = undefined,
CBAVE = undefined,
>(args: {
schema?: S;
schemaFn?: SF;
bindArgsSchemas?: BAS;
handleValidationErrorsShape: HandleValidationErrorsShapeFn<S, CVE>;
handleBindArgsValidationErrorsShape: HandleBindArgsValidationErrorsShapeFn<BAS, CBAVE>;
Expand Down Expand Up @@ -118,19 +119,19 @@ export function actionBuilder<
} else {
// Validate the client inputs in parallel.
const parsedInputs = await Promise.all(
clientInputs.map((input, i) => {
clientInputs.map(async (input, i) => {
// Last client input in the array, main argument (no bind arg).
if (i === clientInputs.length - 1) {
// If schema is undefined, set parsed data to undefined.
if (typeof args.schema === "undefined") {
if (typeof args.schemaFn === "undefined") {
return {
success: true,
data: undefined,
} as const;
}

// Otherwise, parse input with the schema.
return valFn(args.schema, input);
return valFn(await args.schemaFn(), input);
}

// Otherwise, we're processing bind args client inputs.
Expand Down
2 changes: 1 addition & 1 deletion packages/next-safe-action/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ export const createSafeActionClient = <
handleServerErrorLog,
handleReturnedServerError,
validationStrategy: "zod",
schema: undefined,
schemaFn: undefined,
bindArgsSchemas: [],
ctxType: undefined,
metadataSchema: createOpts?.defineMetadataSchema?.(),
Expand Down
33 changes: 18 additions & 15 deletions packages/next-safe-action/src/safe-action-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,11 +13,12 @@ import type {

export class SafeActionClient<
ServerError,
ODVES extends DVES | undefined,
ODVES extends DVES | undefined, // override default validation errors shape
MetadataSchema extends Schema | undefined = undefined,
MD = MetadataSchema extends Schema ? Infer<Schema> : undefined,
Ctx = undefined,
S extends Schema | undefined = undefined,
SF extends (() => Promise<Schema>) | undefined = undefined, // schema function
S extends Schema | undefined = SF extends Function ? Awaited<ReturnType<SF>> : undefined,
const BAS extends readonly Schema[] = [],
CVE = undefined,
const CBAVE = undefined,
Expand All @@ -31,7 +32,7 @@ export class SafeActionClient<
readonly #ctxType = undefined as Ctx;
readonly #metadataSchema: MetadataSchema;
readonly #metadata: MD;
readonly #schema: S;
readonly #schemaFn: SF;
readonly #bindArgsSchemas: BAS;
readonly #handleValidationErrorsShape: HandleValidationErrorsShapeFn<S, CVE>;
readonly #handleBindArgsValidationErrorsShape: HandleBindArgsValidationErrorsShapeFn<BAS, CBAVE>;
Expand All @@ -43,7 +44,7 @@ export class SafeActionClient<
validationStrategy: "typeschema" | "zod";
metadataSchema: MetadataSchema;
metadata: MD;
schema: S;
schemaFn: SF;
bindArgsSchemas: BAS;
handleValidationErrorsShape: HandleValidationErrorsShapeFn<S, CVE>;
handleBindArgsValidationErrorsShape: HandleBindArgsValidationErrorsShapeFn<BAS, CBAVE>;
Expand All @@ -61,7 +62,7 @@ export class SafeActionClient<
this.#validationStrategy = opts.validationStrategy;
this.#metadataSchema = opts.metadataSchema;
this.#metadata = opts.metadata;
this.#schema = (opts.schema ?? undefined) as S;
this.#schemaFn = (opts.schemaFn ?? undefined) as SF;
this.#bindArgsSchemas = opts.bindArgsSchemas ?? [];
this.#handleValidationErrorsShape = opts.handleValidationErrorsShape;
this.#handleBindArgsValidationErrorsShape = opts.handleBindArgsValidationErrorsShape;
Expand All @@ -82,7 +83,7 @@ export class SafeActionClient<
validationStrategy: this.#validationStrategy,
metadataSchema: this.#metadataSchema,
metadata: this.#metadata,
schema: this.#schema,
schemaFn: this.#schemaFn,
bindArgsSchemas: this.#bindArgsSchemas,
handleValidationErrorsShape: this.#handleValidationErrorsShape,
handleBindArgsValidationErrorsShape: this.#handleBindArgsValidationErrorsShape,
Expand All @@ -105,7 +106,7 @@ export class SafeActionClient<
validationStrategy: this.#validationStrategy,
metadataSchema: this.#metadataSchema,
metadata: data,
schema: this.#schema,
schemaFn: this.#schemaFn,
bindArgsSchemas: this.#bindArgsSchemas,
handleValidationErrorsShape: this.#handleValidationErrorsShape,
handleBindArgsValidationErrorsShape: this.#handleBindArgsValidationErrorsShape,
Expand All @@ -122,12 +123,13 @@ export class SafeActionClient<
* {@link https://next-safe-action.dev/docs/safe-action-client/instance-methods#schema See docs for more information}
*/
schema<
OS extends Schema,
OCVE = ODVES extends "flattened" ? FlattenedValidationErrors<ValidationErrors<OS>> : ValidationErrors<OS>,
OS extends Schema | (() => Promise<Schema>),
AS extends Schema = OS extends () => Promise<Schema> ? Awaited<ReturnType<OS>> : OS, // actual schema
OCVE = ODVES extends "flattened" ? FlattenedValidationErrors<ValidationErrors<AS>> : ValidationErrors<AS>,
>(
schema: OS,
utils?: {
handleValidationErrorsShape?: HandleValidationErrorsShapeFn<OS, OCVE>;
handleValidationErrorsShape?: HandleValidationErrorsShapeFn<AS, OCVE>;
}
) {
return new SafeActionClient({
Expand All @@ -137,10 +139,11 @@ export class SafeActionClient<
validationStrategy: this.#validationStrategy,
metadataSchema: this.#metadataSchema,
metadata: this.#metadata,
schema,
// @ts-expect-error
schemaFn: (schema[Symbol.toStringTag] === "AsyncFunction" ? schema : async () => schema) as SF,
bindArgsSchemas: this.#bindArgsSchemas,
handleValidationErrorsShape: (utils?.handleValidationErrorsShape ??
this.#handleValidationErrorsShape) as HandleValidationErrorsShapeFn<OS, OCVE>,
this.#handleValidationErrorsShape) as HandleValidationErrorsShapeFn<AS, OCVE>,
handleBindArgsValidationErrorsShape: this.#handleBindArgsValidationErrorsShape,
ctxType: undefined as Ctx,
defaultValidationErrorsShape: this.#defaultValidationErrorsShape,
Expand Down Expand Up @@ -170,7 +173,7 @@ export class SafeActionClient<
validationStrategy: this.#validationStrategy,
metadataSchema: this.#metadataSchema,
metadata: this.#metadata,
schema: this.#schema,
schemaFn: this.#schemaFn,
bindArgsSchemas,
handleValidationErrorsShape: this.#handleValidationErrorsShape,
handleBindArgsValidationErrorsShape: (utils?.handleBindArgsValidationErrorsShape ??
Expand All @@ -195,7 +198,7 @@ export class SafeActionClient<
ctxType: this.#ctxType,
metadataSchema: this.#metadataSchema,
metadata: this.#metadata,
schema: this.#schema,
schemaFn: this.#schemaFn,
bindArgsSchemas: this.#bindArgsSchemas,
handleValidationErrorsShape: this.#handleValidationErrorsShape,
handleBindArgsValidationErrorsShape: this.#handleBindArgsValidationErrorsShape,
Expand All @@ -218,7 +221,7 @@ export class SafeActionClient<
ctxType: this.#ctxType,
metadataSchema: this.#metadataSchema,
metadata: this.#metadata,
schema: this.#schema,
schemaFn: this.#schemaFn,
bindArgsSchemas: this.#bindArgsSchemas,
handleValidationErrorsShape: this.#handleValidationErrorsShape,
handleBindArgsValidationErrorsShape: this.#handleBindArgsValidationErrorsShape,
Expand Down
2 changes: 1 addition & 1 deletion packages/next-safe-action/src/typeschema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ export const createSafeActionClient = <
handleServerErrorLog,
handleReturnedServerError,
validationStrategy: "typeschema",
schema: undefined,
schemaFn: undefined,
bindArgsSchemas: [],
ctxType: undefined,
metadataSchema: createOpts?.defineMetadataSchema?.(),
Expand Down
7 changes: 5 additions & 2 deletions packages/next-safe-action/src/validation-errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,8 +70,11 @@ export class ActionServerValidationError<S extends Schema> extends Error {
*
* {@link https://next-safe-action.dev/docs/recipes/additional-validation-errors#returnvalidationerrors See docs for more information}
*/
export function returnValidationErrors<S extends Schema>(schema: S, validationErrors: ValidationErrors<S>): never {
throw new ActionServerValidationError<S>(validationErrors);
export function returnValidationErrors<
S extends Schema | (() => Promise<Schema>),
AS extends Schema = S extends () => Promise<Schema> ? Awaited<ReturnType<S>> : S, // actual schema
>(schema: S, validationErrors: ValidationErrors<AS>): never {
throw new ActionServerValidationError<AS>(validationErrors);
}

/**
Expand Down
4 changes: 4 additions & 0 deletions website/docs/migrations/v6-to-v7.md
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,10 @@ This is customizable by using the `handleValidationErrorsShape`/`handleBindArgsV

Sometimes it's not necessary to define an action with input. In this case, you can omit the [`schema`](/docs/safe-action-client/instance-methods#schema) method and use directly the [`action`/`stateAction`](/docs/safe-action-client/instance-methods#action--stateaction) method.

### [Support passing schema via async function](https://github.com/TheEdoRan/next-safe-action/issues/155)

When working with i18n solutions, often you'll find implementations that require awaiting a `getTranslations` function in order to get the translations, that then get passed to the schema. Starting from version 7, next-safe-action allows you to pass an async function to the [`schema`](/docs/safe-action-client/instance-methods#schema) method, that returns a promise of type `Schema`. More information about this feature can be found in [this discussion](https://github.com/TheEdoRan/next-safe-action/discussions/111) on GitHub and in the [i18n](/docs/recipes/i18n) recipe.

### [Support stateful actions using React `useActionState` hook](https://github.com/TheEdoRan/next-safe-action/issues/91)

React added a hook called `useActionState` that replaces the previous `useFormState` hook and improves it. next-safe-action v7 uses it under the hood in the exported [`useStateAction`](/docs/execution/hooks/usestateaction) hook, that keeps track of the state of the action execution.
Expand Down
2 changes: 1 addition & 1 deletion website/docs/recipes/additional-validation-errors.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ When registering a new user, we also need to check if the email is already store

```typescript
import { returnValidationErrors } from "next-safe-action";
import { action } from "@/lib/safe-action";
import { actionClient } from "@/lib/safe-action";

// Here we're using the same schema declared above.
const signupAction = actionClient
Expand Down
28 changes: 28 additions & 0 deletions website/docs/recipes/i18n.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
---
sidebar_position: 5
description: Learn how to use next-safe-action with a i18n solution.
---

# i18n

If you're using a i18n solution, there's a high probability that you'll need to await the translations and then pass them to schemas.\
next-safe-action allows you to do that by passing an async function to the [`schema`](/docs/safe-action-client/instance-methods#schema) method that returns a promise with the schema.\
The setup is pretty simple:

```typescript
"use server";

import { actionClient } from "@/lib/safe-action";
import { z } from "zod";
import { getTranslations } from "my-i18n-lib";

async function getSchema() {
// This is an example of a i18n setup.
const t = await getTranslations();
return mySchema(t); // this is the schema that will be used to validate and parse the input
}

export const myAction = actionClient.schema(getSchema).action(async ({ parsedInput }) => {
// Do something useful here...
});
```
2 changes: 1 addition & 1 deletion website/docs/safe-action-client/instance-methods.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ metadata(data: Metadata) => new SafeActionClient()
schema(schema: S, utils?: { handleValidationErrorsShape?: HandleValidationErrorsShapeFn } }) => new SafeActionClient()
```

`schema` accepts an **optional** input schema of type `Schema` (from TypeSchema) and an optional `utils` object that accepts a [`handleValidationErrorsShape`](/docs/recipes/customize-validation-errors-format) function. The schema is used to define the arguments that the safe action will receive, the optional [`handleValidationErrorsShape`](/docs/recipes/customize-validation-errors-format) function is used to return a custom format for validation errors. If you don't pass an input schema, `parsedInput` and validation errors will be typed `undefined`, and `clientInput` will be typed `void`. It returns a new instance of the safe action client.
`schema` accepts an input schema of type `Schema` (from TypeSchema) or a function that returns a promise of type `Schema` and an optional `utils` object that accepts a [`handleValidationErrorsShape`](/docs/recipes/customize-validation-errors-format) function. The schema is used to define the arguments that the safe action will receive, the optional [`handleValidationErrorsShape`](/docs/recipes/customize-validation-errors-format) function is used to return a custom format for validation errors. If you don't pass an input schema, `parsedInput` and validation errors will be typed `undefined`, and `clientInput` will be typed `void`. It returns a new instance of the safe action client.

## `bindArgsSchemas`

Expand Down

0 comments on commit 4dcf742

Please sign in to comment.