From c9d02e0ec2113df51226b83a5a11fa6bc8761f97 Mon Sep 17 00:00:00 2001 From: Edoardo Ranghieri Date: Mon, 22 Jul 2024 02:09:01 +0200 Subject: [PATCH] feat: add action `throwValidationErrors` and `throwServerError` util props (#208) Code in this PR adds `throwValidationErrors` and `throwServerError` optional properties at the action level. --- .../src/__tests__/server-error.test.ts | 11 +++ .../src/__tests__/validation-errors.test.ts | 94 +++++++++++++++++++ .../next-safe-action/src/action-builder.ts | 35 ++++--- packages/next-safe-action/src/index.types.ts | 6 +- .../src/safe-action-client.ts | 10 +- .../{action-callbacks.md => action-utils.md} | 33 ++++++- website/docs/migrations/v6-to-v7.md | 2 +- .../safe-action-client/instance-methods.md | 6 +- website/docs/types.md | 8 +- 9 files changed, 174 insertions(+), 31 deletions(-) rename website/docs/execution/{action-callbacks.md => action-utils.md} (50%) diff --git a/packages/next-safe-action/src/__tests__/server-error.test.ts b/packages/next-safe-action/src/__tests__/server-error.test.ts index bdc79f8c..cf874baa 100644 --- a/packages/next-safe-action/src/__tests__/server-error.test.ts +++ b/packages/next-safe-action/src/__tests__/server-error.test.ts @@ -93,6 +93,17 @@ test("known error occurred in middleware function is unmasked", async () => { assert.deepStrictEqual(actualResult, expectedResult); }); +test("error occurred with `throwServerError` set to true at the action level throws", async () => { + const action = ac1.action( + async () => { + throw new Error("Something bad happened"); + }, + { throwServerError: true } + ); + + assert.rejects(async () => await action()); +}); + // Server error is an object with a 'message' property. const ac2 = createSafeActionClient({ validationAdapter: zodAdapter(), diff --git a/packages/next-safe-action/src/__tests__/validation-errors.test.ts b/packages/next-safe-action/src/__tests__/validation-errors.test.ts index 25e83adf..96b26d20 100644 --- a/packages/next-safe-action/src/__tests__/validation-errors.test.ts +++ b/packages/next-safe-action/src/__tests__/validation-errors.test.ts @@ -541,6 +541,25 @@ test("action with errors set via `returnValidationErrors` gives back an object w // `throwValidationErrors` tests. +// test without `throwValidationErrors` set at the instance level, just set at the action level. +test("action with validation errors and `throwValidationErrors` option set to true at the action level throws", async () => { + const schema = z.object({ + username: z.string().min(3), + password: z.string().min(3), + }); + + const action = dac.schema(schema).action( + async () => { + return { + ok: true, + }; + }, + { throwValidationErrors: true } + ); + + assert.rejects(async () => await action({ username: "12", password: "34" })); +}); + const tveac = createSafeActionClient({ validationAdapter: zodAdapter(), throwValidationErrors: true, @@ -580,3 +599,78 @@ test("action with server validation errors and `throwValidationErrors` option se assert.rejects(async () => await action({ username: "1234", password: "5678" })); }); + +test("action with validation errors and `throwValidationErrors` option set to true both in client and action throws", async () => { + const schema = z.object({ + username: z.string().min(3), + password: z.string().min(3), + }); + + const action = tveac.schema(schema).action( + async () => { + return { + ok: true, + }; + }, + { throwValidationErrors: true } + ); + + assert.rejects(async () => await action({ username: "12", password: "34" })); +}); + +test("action with validation errors and overridden `throwValidationErrors` set to false at the action level doesn't throw", async () => { + const schema = z.object({ + user: z.object({ + id: z.string().min(36).uuid(), + }), + store: z.object({ + id: z.string().min(36).uuid(), + product: z.object({ + id: z.string().min(36).uuid(), + }), + }), + }); + + const action = tveac.schema(schema).action( + async () => { + return { + ok: true, + }; + }, + { throwValidationErrors: false } + ); + + const actualResult = await action({ + user: { + id: "invalid_uuid", + }, + store: { + id: "invalid_uuid", + product: { + id: "invalid_uuid", + }, + }, + }); + + const expectedResult = { + validationErrors: { + user: { + id: { + _errors: ["String must contain at least 36 character(s)", "Invalid uuid"], + }, + }, + store: { + id: { + _errors: ["String must contain at least 36 character(s)", "Invalid uuid"], + }, + product: { + id: { + _errors: ["String must contain at least 36 character(s)", "Invalid uuid"], + }, + }, + }, + }, + }; + + assert.deepStrictEqual(actualResult, expectedResult); +}); diff --git a/packages/next-safe-action/src/action-builder.ts b/packages/next-safe-action/src/action-builder.ts index 85ef67f0..ebd9ad9c 100644 --- a/packages/next-safe-action/src/action-builder.ts +++ b/packages/next-safe-action/src/action-builder.ts @@ -5,10 +5,10 @@ import type { Infer, InferArray, InferIn, InferInArray, Schema, ValidationAdapte import type { MiddlewareFn, MiddlewareResult, - SafeActionCallbacks, SafeActionClientOpts, SafeActionFn, SafeActionResult, + SafeActionUtils, SafeStateActionFn, ServerCodeFn, StateServerCodeFn, @@ -53,13 +53,13 @@ export function actionBuilder< function buildAction({ withState }: { withState: false }): { action: ( serverCodeFn: ServerCodeFn, - cb?: SafeActionCallbacks + utils?: SafeActionUtils ) => SafeActionFn; }; function buildAction({ withState }: { withState: true }): { action: ( serverCodeFn: StateServerCodeFn, - cb?: SafeActionCallbacks + utils?: SafeActionUtils ) => SafeStateActionFn; }; function buildAction({ withState }: { withState: boolean }) { @@ -68,7 +68,7 @@ export function actionBuilder< serverCodeFn: | ServerCodeFn | StateServerCodeFn, - cb?: SafeActionCallbacks + utils?: SafeActionUtils ) => { return async (...clientInputs: unknown[]) => { let prevCtx: unknown = undefined; @@ -260,7 +260,7 @@ export function actionBuilder< // If an internal framework error occurred, throw it, so it will be processed by Next.js. if (frameworkError) { await Promise.resolve( - cb?.onSuccess?.({ + utils?.onSuccess?.({ data: undefined, metadata: args.metadata, ctx: prevCtx as Ctx, @@ -274,7 +274,7 @@ export function actionBuilder< ); await Promise.resolve( - cb?.onSettled?.({ + utils?.onSettled?.({ metadata: args.metadata, ctx: prevCtx as Ctx, clientInput: clientInputs.at(-1) as S extends Schema ? InferIn : undefined, @@ -291,10 +291,17 @@ export function actionBuilder< const actionResult: SafeActionResult = {}; if (typeof middlewareResult.validationErrors !== "undefined") { - if (args.throwValidationErrors) { + // Throw validation errors if either `throwValidationErrors` property at the action or instance level is `true`. + // If `throwValidationErrors` property at the action is `false`, do not throw validation errors, since it + // has a higher priority than the instance one. + if ( + (utils?.throwValidationErrors || args.throwValidationErrors) && + utils?.throwValidationErrors !== false + ) { throw new ActionValidationError(middlewareResult.validationErrors as CVE); + } else { + actionResult.validationErrors = middlewareResult.validationErrors as CVE; } - actionResult.validationErrors = middlewareResult.validationErrors as CVE; } if (typeof middlewareResult.bindArgsValidationErrors !== "undefined") { @@ -302,7 +309,11 @@ export function actionBuilder< } if (typeof middlewareResult.serverError !== "undefined") { - actionResult.serverError = middlewareResult.serverError; + if (utils?.throwServerError) { + throw middlewareResult.serverError; + } else { + actionResult.serverError = middlewareResult.serverError; + } } if (middlewareResult.success) { @@ -311,7 +322,7 @@ export function actionBuilder< } await Promise.resolve( - cb?.onSuccess?.({ + utils?.onSuccess?.({ metadata: args.metadata, ctx: prevCtx as Ctx, data: actionResult.data as Data, @@ -325,7 +336,7 @@ export function actionBuilder< ); } else { await Promise.resolve( - cb?.onError?.({ + utils?.onError?.({ metadata: args.metadata, ctx: prevCtx as Ctx, clientInput: clientInputs.at(-1) as S extends Schema ? InferIn : undefined, @@ -337,7 +348,7 @@ export function actionBuilder< // onSettled, if provided, is always executed. await Promise.resolve( - cb?.onSettled?.({ + utils?.onSettled?.({ metadata: args.metadata, ctx: prevCtx as Ctx, clientInput: clientInputs.at(-1) as S extends Schema ? InferIn : undefined, diff --git a/packages/next-safe-action/src/index.types.ts b/packages/next-safe-action/src/index.types.ts index 1cb4a4a3..73c41389 100644 --- a/packages/next-safe-action/src/index.types.ts +++ b/packages/next-safe-action/src/index.types.ts @@ -145,9 +145,9 @@ export type StateServerCodeFn< ) => Promise; /** - * Type of action execution callbacks. These are called after the action is executed, on the server side. + * Type of action execution utils. It includes action callbacks and other utils. */ -export type SafeActionCallbacks< +export type SafeActionUtils< ServerError, MD, Ctx, @@ -157,6 +157,8 @@ export type SafeActionCallbacks< CBAVE, Data, > = { + throwServerError?: boolean; + throwValidationErrors?: boolean; onSuccess?: (args: { data?: Data; metadata: MD; diff --git a/packages/next-safe-action/src/safe-action-client.ts b/packages/next-safe-action/src/safe-action-client.ts index f0938437..ca918927 100644 --- a/packages/next-safe-action/src/safe-action-client.ts +++ b/packages/next-safe-action/src/safe-action-client.ts @@ -4,8 +4,8 @@ import type { Infer, Schema, ValidationAdapter } from "./adapters/types"; import type { DVES, MiddlewareFn, - SafeActionCallbacks, SafeActionClientOpts, + SafeActionUtils, ServerCodeFn, StateServerCodeFn, } from "./index.types"; @@ -213,7 +213,7 @@ export class SafeActionClient< */ action( serverCodeFn: ServerCodeFn, - cb?: SafeActionCallbacks + utils?: SafeActionUtils ) { return actionBuilder({ handleReturnedServerError: this.#handleReturnedServerError, @@ -228,7 +228,7 @@ export class SafeActionClient< handleValidationErrorsShape: this.#handleValidationErrorsShape, handleBindArgsValidationErrorsShape: this.#handleBindArgsValidationErrorsShape, throwValidationErrors: this.#throwValidationErrors, - }).action(serverCodeFn, cb); + }).action(serverCodeFn, utils); } /** @@ -241,7 +241,7 @@ export class SafeActionClient< */ stateAction( serverCodeFn: StateServerCodeFn, - cb?: SafeActionCallbacks + utils?: SafeActionUtils ) { return actionBuilder({ handleReturnedServerError: this.#handleReturnedServerError, @@ -256,6 +256,6 @@ export class SafeActionClient< handleValidationErrorsShape: this.#handleValidationErrorsShape, handleBindArgsValidationErrorsShape: this.#handleBindArgsValidationErrorsShape, throwValidationErrors: this.#throwValidationErrors, - }).stateAction(serverCodeFn, cb); + }).stateAction(serverCodeFn, utils); } } diff --git a/website/docs/execution/action-callbacks.md b/website/docs/execution/action-utils.md similarity index 50% rename from website/docs/execution/action-callbacks.md rename to website/docs/execution/action-utils.md index 0fd39f4a..751387dd 100644 --- a/website/docs/execution/action-callbacks.md +++ b/website/docs/execution/action-utils.md @@ -1,11 +1,20 @@ --- sidebar_position: 3 -description: Action callbacks are a way to perform custom logic after the action is executed, on the server. +description: Action utils is an object with useful properties and callbacks functions that you can use to customize the action execution flow. --- -# Action callbacks +# Action utils -With action callbacks you can perform custom logic after the action is executed, on the server side. You can provide them to [`action`/`stateAction`](/docs/safe-action-client/instance-methods#action--stateaction) method as the second argument, after the server code function: +Action utils is an object with some useful properties and callbacks passed as the second argument of the [`action`/`stateAction`](/docs/safe-action-client/instance-methods#action--stateaction) method. + +## Throw errors when they occur + +Starting from v7.4.0, you can now pass optional `throwServerError` and `throwValidationErrors` properties at the action level, if you want or need that behavior. Note that the `throwValidationErrors` property set at the action level has a higher priority than the one at the instance level, so if you set it to `false` while the one at the instance level is `true`, validation errors will **not** be thrown. + + +## Callbacks + +With action callbacks you can perform custom logic after the action is executed, on the server side. You can provide them to [`action`/`stateAction`](/docs/safe-action-client/instance-methods#action--stateaction) method in the second argument, after the server code function: ```tsx import { actionClient } from "@/lib/safe-action"; @@ -27,8 +36,22 @@ const action = actionClient hasRedirected, hasNotFound, }) => {}, - onError: ({ error, ctx, metadata, clientInput, bindArgsClientInputs }) => {}, - onSettled: ({ result, ctx, metadata, clientInput, bindArgsClientInputs }) => {}, + onError: ({ + error, + ctx, + metadata, + clientInput, + bindArgsClientInputs + }) => {}, + onSettled: ({ + result, + ctx, + metadata, + clientInput, + bindArgsClientInputs, + hasRedirected, + hasNotFound + }) => {}, }); ``` diff --git a/website/docs/migrations/v6-to-v7.md b/website/docs/migrations/v6-to-v7.md index b30e7ac9..389140bd 100644 --- a/website/docs/migrations/v6-to-v7.md +++ b/website/docs/migrations/v6-to-v7.md @@ -149,7 +149,7 @@ When working with i18n solutions, often you'll find implementations that require ### [Support action execution callbacks](https://github.com/TheEdoRan/next-safe-action/issues/162) -It's sometimes useful to be able to execute custom logic on the server side after an action succeeds or fails. Starting from version 7, next-safe-action allows you to pass action callbacks when defining an action. More information about this feature can be found [here](/docs/execution/action-callbacks). +It's sometimes useful to be able to execute custom logic on the server side after an action succeeds or fails. Starting from version 7, next-safe-action allows you to pass action callbacks when defining an action. More information about this feature can be found [here](/docs/execution/action-utils#callbacks). ### [Support stateful actions using React `useActionState` hook](https://github.com/TheEdoRan/next-safe-action/issues/91) diff --git a/website/docs/safe-action-client/instance-methods.md b/website/docs/safe-action-client/instance-methods.md index eadaa195..9d6640b6 100644 --- a/website/docs/safe-action-client/instance-methods.md +++ b/website/docs/safe-action-client/instance-methods.md @@ -44,14 +44,14 @@ bindArgsSchemas(bindArgsSchemas: BAS, bindArgsUtils?: { handleBindArgsValidation ## `action` / `stateAction` ```typescript -action(serverCodeFn: ServerCodeFn, cb?: SafeActionCallbacks) => SafeActionFn +action(serverCodeFn: ServerCodeFn, utils?: SafeActionUtils) => SafeActionFn ``` ```typescript -stateAction(serverCodeFn: StateServerCodeFn, cb?: SafeActionCallbacks) => SafeStateActionFn +stateAction(serverCodeFn: StateServerCodeFn, utils?: SafeActionUtils) => SafeStateActionFn ``` -`action`/`stateAction` is the final method in the list. It accepts a [`serverCodeFn`](#servercodefn) of type [`ServerCodeFn`](/docs/types#servercodefn)/[`StateServerCodeFn`](/docs/types#stateservercodefn) and an object with optional [action callbacks](/docs/execution/action-callbacks), and it returns a new safe action function of type [`SafeActionFn`](/docs/types#safeactionfn)/[`SafeStateActionFn`](/docs/types#safestateactionfn), which can be called from your components. When an action doesn't need input arguments, you can directly use this method without passing a schema to [`schema`](#schema) method. +`action`/`stateAction` is the final method in the list. It accepts a [`serverCodeFn`](#servercodefn) of type [`ServerCodeFn`](/docs/types#servercodefn)/[`StateServerCodeFn`](/docs/types#stateservercodefn) and an optional object with [action utils](/docs/execution/action-utils), and it returns a new safe action function of type [`SafeActionFn`](/docs/types#safeactionfn)/[`SafeStateActionFn`](/docs/types#safestateactionfn), which can be called from your components. When an action doesn't need input arguments, you can directly use this method without passing a schema to [`schema`](#schema) method. When the action is executed, all middleware functions in the chain will be called at runtime, in the order they were defined. diff --git a/website/docs/types.md b/website/docs/types.md index 7be406db..22c34b56 100644 --- a/website/docs/types.md +++ b/website/docs/types.md @@ -191,12 +191,12 @@ export type StateServerCodeFn< ) => Promise; ``` -### `SafeActionCallbacks` +### `SafeActionUtils` -Type of action execution callbacks. These are called after the action is executed, on the server side. +Type of action execution utils. It includes action callbacks and other utils. ```typescript -export type SafeActionCallbacks< +export type SafeActionUtils< ServerError, S extends Schema | undefined, BAS extends readonly Schema[], @@ -204,6 +204,8 @@ export type SafeActionCallbacks< CBAVE, Data, > = { + throwServerError?: boolean; + throwValidationErrors?: boolean; onSuccess?: (args: { data?: Data; clientInput: S extends Schema ? InferIn : undefined;