Skip to content

Commit

Permalink
feat: add action throwValidationErrors and throwServerError util …
Browse files Browse the repository at this point in the history
…props (#208)

Code in this PR adds `throwValidationErrors` and `throwServerError` optional properties at the action level.
  • Loading branch information
TheEdoRan authored Jul 22, 2024
1 parent 84f94fb commit c9d02e0
Show file tree
Hide file tree
Showing 9 changed files with 174 additions and 31 deletions.
11 changes: 11 additions & 0 deletions packages/next-safe-action/src/__tests__/server-error.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down
94 changes: 94 additions & 0 deletions packages/next-safe-action/src/__tests__/validation-errors.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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);
});
35 changes: 23 additions & 12 deletions packages/next-safe-action/src/action-builder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,10 @@ import type { Infer, InferArray, InferIn, InferInArray, Schema, ValidationAdapte
import type {
MiddlewareFn,
MiddlewareResult,
SafeActionCallbacks,
SafeActionClientOpts,
SafeActionFn,
SafeActionResult,
SafeActionUtils,
SafeStateActionFn,
ServerCodeFn,
StateServerCodeFn,
Expand Down Expand Up @@ -53,13 +53,13 @@ export function actionBuilder<
function buildAction({ withState }: { withState: false }): {
action: <Data>(
serverCodeFn: ServerCodeFn<MD, Ctx, S, BAS, Data>,
cb?: SafeActionCallbacks<ServerError, MD, Ctx, S, BAS, CVE, CBAVE, Data>
utils?: SafeActionUtils<ServerError, MD, Ctx, S, BAS, CVE, CBAVE, Data>
) => SafeActionFn<ServerError, S, BAS, CVE, CBAVE, Data>;
};
function buildAction({ withState }: { withState: true }): {
action: <Data>(
serverCodeFn: StateServerCodeFn<ServerError, MD, Ctx, S, BAS, CVE, CBAVE, Data>,
cb?: SafeActionCallbacks<ServerError, MD, Ctx, S, BAS, CVE, CBAVE, Data>
utils?: SafeActionUtils<ServerError, MD, Ctx, S, BAS, CVE, CBAVE, Data>
) => SafeStateActionFn<ServerError, S, BAS, CVE, CBAVE, Data>;
};
function buildAction({ withState }: { withState: boolean }) {
Expand All @@ -68,7 +68,7 @@ export function actionBuilder<
serverCodeFn:
| ServerCodeFn<MD, Ctx, S, BAS, Data>
| StateServerCodeFn<ServerError, MD, Ctx, S, BAS, CVE, CBAVE, Data>,
cb?: SafeActionCallbacks<ServerError, MD, Ctx, S, BAS, CVE, CBAVE, Data>
utils?: SafeActionUtils<ServerError, MD, Ctx, S, BAS, CVE, CBAVE, Data>
) => {
return async (...clientInputs: unknown[]) => {
let prevCtx: unknown = undefined;
Expand Down Expand Up @@ -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,
Expand All @@ -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<S> : undefined,
Expand All @@ -291,18 +291,29 @@ export function actionBuilder<
const actionResult: SafeActionResult<ServerError, S, BAS, CVE, CBAVE, Data> = {};

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") {
actionResult.bindArgsValidationErrors = middlewareResult.bindArgsValidationErrors as CBAVE;
}

if (typeof middlewareResult.serverError !== "undefined") {
actionResult.serverError = middlewareResult.serverError;
if (utils?.throwServerError) {
throw middlewareResult.serverError;
} else {
actionResult.serverError = middlewareResult.serverError;
}
}

if (middlewareResult.success) {
Expand All @@ -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,
Expand All @@ -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<S> : undefined,
Expand All @@ -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<S> : undefined,
Expand Down
6 changes: 4 additions & 2 deletions packages/next-safe-action/src/index.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -145,9 +145,9 @@ export type StateServerCodeFn<
) => Promise<Data>;

/**
* 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,
Expand All @@ -157,6 +157,8 @@ export type SafeActionCallbacks<
CBAVE,
Data,
> = {
throwServerError?: boolean;
throwValidationErrors?: boolean;
onSuccess?: (args: {
data?: Data;
metadata: MD;
Expand Down
10 changes: 5 additions & 5 deletions packages/next-safe-action/src/safe-action-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,8 @@ import type { Infer, Schema, ValidationAdapter } from "./adapters/types";
import type {
DVES,
MiddlewareFn,
SafeActionCallbacks,
SafeActionClientOpts,
SafeActionUtils,
ServerCodeFn,
StateServerCodeFn,
} from "./index.types";
Expand Down Expand Up @@ -213,7 +213,7 @@ export class SafeActionClient<
*/
action<Data>(
serverCodeFn: ServerCodeFn<MD, Ctx, S, BAS, Data>,
cb?: SafeActionCallbacks<ServerError, MD, Ctx, S, BAS, CVE, CBAVE, Data>
utils?: SafeActionUtils<ServerError, MD, Ctx, S, BAS, CVE, CBAVE, Data>
) {
return actionBuilder({
handleReturnedServerError: this.#handleReturnedServerError,
Expand All @@ -228,7 +228,7 @@ export class SafeActionClient<
handleValidationErrorsShape: this.#handleValidationErrorsShape,
handleBindArgsValidationErrorsShape: this.#handleBindArgsValidationErrorsShape,
throwValidationErrors: this.#throwValidationErrors,
}).action(serverCodeFn, cb);
}).action(serverCodeFn, utils);
}

/**
Expand All @@ -241,7 +241,7 @@ export class SafeActionClient<
*/
stateAction<Data>(
serverCodeFn: StateServerCodeFn<ServerError, MD, Ctx, S, BAS, CVE, CBAVE, Data>,
cb?: SafeActionCallbacks<ServerError, MD, Ctx, S, BAS, CVE, CBAVE, Data>
utils?: SafeActionUtils<ServerError, MD, Ctx, S, BAS, CVE, CBAVE, Data>
) {
return actionBuilder({
handleReturnedServerError: this.#handleReturnedServerError,
Expand All @@ -256,6 +256,6 @@ export class SafeActionClient<
handleValidationErrorsShape: this.#handleValidationErrorsShape,
handleBindArgsValidationErrorsShape: this.#handleBindArgsValidationErrorsShape,
throwValidationErrors: this.#throwValidationErrors,
}).stateAction(serverCodeFn, cb);
}).stateAction(serverCodeFn, utils);
}
}
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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
}) => {},
});
```

Expand Down
2 changes: 1 addition & 1 deletion website/docs/migrations/v6-to-v7.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
6 changes: 3 additions & 3 deletions website/docs/safe-action-client/instance-methods.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
Loading

0 comments on commit c9d02e0

Please sign in to comment.