Skip to content

Commit

Permalink
feat: support multiple validators via TypeSchema
Browse files Browse the repository at this point in the history
Thanks to TypeSchema library, we can now support multiple validators, so next-safe-action is no
longer limited to just Zod validation. More information here: https://typeschema.com/#coverage
  • Loading branch information
TheEdoRan committed Jan 2, 2024
1 parent 1cbcace commit 93591fb
Show file tree
Hide file tree
Showing 6 changed files with 167 additions and 66 deletions.
89 changes: 85 additions & 4 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

9 changes: 5 additions & 4 deletions packages/next-safe-action/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -61,16 +61,17 @@
"prettier": "^3.1.1",
"react": "18.2.0",
"tsup": "^8.0.1",
"typescript": "^5.3.3",
"zod": ">=3.22.4"
"typescript": "^5.3.3"
},
"peerDependencies": {
"next": ">= 14.0.0",
"react": ">= 18.2.0",
"zod": ">= 3.0.0"
"react": ">= 18.2.0"
},
"repository": {
"type": "git",
"url": "https://github.com/TheEdoRan/next-safe-action.git"
},
"dependencies": {
"@decs/typeschema": "^0.12.1"
}
}
52 changes: 26 additions & 26 deletions packages/next-safe-action/src/hooks.ts
Original file line number Diff line number Diff line change
@@ -1,25 +1,25 @@
"use client";

import type { InferIn, Schema } from "@decs/typeschema";
import { isNotFoundError } from "next/dist/client/components/not-found.js";
import { isRedirectError } from "next/dist/client/components/redirect.js";
import { useCallback, useEffect, useOptimistic, useRef, useState, useTransition } from "react";
import {} from "react/experimental";
import type { z } from "zod";
import type { HookActionStatus, HookCallbacks, HookResult, SafeAction } from "./types";
import { isError } from "./utils";

// UTILS

const DEFAULT_RESULT: HookResult<z.ZodTypeAny, any> = {
const DEFAULT_RESULT = {
data: undefined,
fetchError: undefined,
serverError: undefined,
validationErrors: undefined,
fetchError: undefined,
};
} satisfies HookResult<any, any>;

const getActionStatus = <const Schema extends z.ZodTypeAny, const Data>(
const getActionStatus = <const S extends Schema, const Data>(
isExecuting: boolean,
result: HookResult<Schema, Data>
result: HookResult<S, Data>
): HookActionStatus => {
if (isExecuting) {
return "executing";
Expand All @@ -36,12 +36,12 @@ const getActionStatus = <const Schema extends z.ZodTypeAny, const Data>(
return "idle";
};

const useActionCallbacks = <const Schema extends z.ZodTypeAny, const Data>(
result: HookResult<Schema, Data>,
input: z.input<Schema>,
const useActionCallbacks = <const S extends Schema, const Data>(
result: HookResult<S, Data>,
input: InferIn<S>,
status: HookActionStatus,
reset: () => void,
cb?: HookCallbacks<Schema, Data>
cb?: HookCallbacks<S, Data>
) => {
const onExecuteRef = useRef(cb?.onExecute);
const onSuccessRef = useRef(cb?.onSuccess);
Expand Down Expand Up @@ -82,19 +82,19 @@ const useActionCallbacks = <const Schema extends z.ZodTypeAny, const Data>(
*
* {@link https://next-safe-action.dev/docs/usage-from-client/hooks/useaction See an example}
*/
export const useAction = <const Schema extends z.ZodTypeAny, const Data>(
safeAction: SafeAction<Schema, Data>,
callbacks?: HookCallbacks<Schema, Data>
export const useAction = <const S extends Schema, const Data>(
safeAction: SafeAction<S, Data>,
callbacks?: HookCallbacks<S, Data>
) => {
const [, startTransition] = useTransition();
const [result, setResult] = useState<HookResult<Schema, Data>>(DEFAULT_RESULT);
const [input, setInput] = useState<z.input<Schema>>();
const [result, setResult] = useState<HookResult<S, Data>>(DEFAULT_RESULT);
const [input, setInput] = useState<InferIn<S>>();
const [isExecuting, setIsExecuting] = useState(false);

const status = getActionStatus<Schema, Data>(isExecuting, result);
const status = getActionStatus<S, Data>(isExecuting, result);

const execute = useCallback(
(input: z.input<Schema>) => {
(input: InferIn<S>) => {
setInput(input);
setIsExecuting(true);

Expand Down Expand Up @@ -141,26 +141,26 @@ export const useAction = <const Schema extends z.ZodTypeAny, const Data>(
*
* {@link https://next-safe-action.dev/docs/usage-from-client/hooks/useoptimisticaction See an example}
*/
export const useOptimisticAction = <const Schema extends z.ZodTypeAny, const Data>(
safeAction: SafeAction<Schema, Data>,
export const useOptimisticAction = <const S extends Schema, const Data>(
safeAction: SafeAction<S, Data>,
initialOptimisticData: Data,
reducer: (state: Data, input: z.input<Schema>) => Data,
callbacks?: HookCallbacks<Schema, Data>
reducer: (state: Data, input: InferIn<S>) => Data,
callbacks?: HookCallbacks<S, Data>
) => {
const [, startTransition] = useTransition();
const [result, setResult] = useState<HookResult<Schema, Data>>(DEFAULT_RESULT);
const [input, setInput] = useState<z.input<Schema>>();
const [result, setResult] = useState<HookResult<S, Data>>(DEFAULT_RESULT);
const [input, setInput] = useState<InferIn<S>>();
const [isExecuting, setIsExecuting] = useState(false);

const [optimisticData, setOptimisticState] = useOptimistic<Data, z.input<Schema>>(
const [optimisticData, setOptimisticState] = useOptimistic<Data, InferIn<S>>(
initialOptimisticData,
reducer
);

const status = getActionStatus<Schema, Data>(isExecuting, result);
const status = getActionStatus<S, Data>(isExecuting, result);

const execute = useCallback(
(input: z.input<Schema>) => {
(input: InferIn<S>) => {
setInput(input);
setIsExecuting(true);

Expand Down
24 changes: 10 additions & 14 deletions packages/next-safe-action/src/server.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
import type { Schema } from "@decs/typeschema";
import { wrap } from "@decs/typeschema";
import { isNotFoundError } from "next/dist/client/components/not-found.js";
import { isRedirectError } from "next/dist/client/components/redirect.js";
import type { z } from "zod";
import type { SafeAction, ServerCodeFn } from "./types";
import type { MaybePromise } from "./utils";
import { DEFAULT_SERVER_ERROR, isError } from "./utils";
import { DEFAULT_SERVER_ERROR, buildValidationErrors, isError } from "./utils";

/**
* Initialize a new action client.
Expand Down Expand Up @@ -35,25 +36,20 @@ export const createSafeActionClient = <Context>(createOpts?: {
// It expects an input schema and a `serverCode` function, so the action
// knows what to do on the server when called by the client.
// It returns a function callable by the client.
const actionBuilder = <const Schema extends z.ZodTypeAny, const Data>(
schema: Schema,
serverCode: ServerCodeFn<Schema, Data, Context>
): SafeAction<Schema, Data> => {
const actionBuilder = <const S extends Schema, const Data>(
schema: S,
serverCode: ServerCodeFn<S, Data, Context>
): SafeAction<S, Data> => {
// This is the function called by client. If `input` fails the schema
// parsing, the function will return a `validationError` object, containing
// all the invalid fields provided.
return async (clientInput) => {
try {
const parsedInput = await schema.safeParseAsync(clientInput);

if (!parsedInput.success) {
const { formErrors, fieldErrors } = parsedInput.error.flatten();
const parsedInput = await wrap(schema).validate(clientInput);

if ("issues" in parsedInput) {
return {
validationErrors: {
form: formErrors.length ? formErrors : undefined,
fields: fieldErrors,
} as Awaited<ReturnType<SafeAction<Schema, Data>>>["validationErrors"],
validationErrors: buildValidationErrors(parsedInput.issues),
};
}

Expand Down
31 changes: 13 additions & 18 deletions packages/next-safe-action/src/types.ts
Original file line number Diff line number Diff line change
@@ -1,25 +1,22 @@
import type { z } from "zod";
import type { Infer, InferIn, Schema } from "@decs/typeschema";
import type { MaybePromise } from "./utils";

// CLIENT

/**
* Type of the function called from Client Components with typesafe input data.
*/
export type SafeAction<Schema extends z.ZodTypeAny, Data> = (input: z.input<Schema>) => Promise<{
export type SafeAction<S extends Schema, Data> = (input: InferIn<S>) => Promise<{
data?: Data;
serverError?: string;
validationErrors?: {
form: string[] | undefined;
fields: Partial<Record<keyof z.infer<Schema>, string[]>>;
};
validationErrors?: Partial<Record<keyof Infer<S> | "_root", string[]>>;
}>;

/**
* Type of the function that executes server code when defining a new safe action.
*/
export type ServerCodeFn<Schema extends z.ZodTypeAny, Data, Context> = (
parsedInput: z.infer<Schema>,
export type ServerCodeFn<S extends Schema, Data, Context> = (
parsedInput: Infer<S>,
ctx: Context
) => Promise<Data>;

Expand All @@ -28,26 +25,24 @@ export type ServerCodeFn<Schema extends z.ZodTypeAny, Data, Context> = (
/**
* Type of `result` object returned by `useAction` and `useOptimisticAction` hooks.
*/
export type HookResult<Schema extends z.ZodTypeAny, Data> = Awaited<
ReturnType<SafeAction<Schema, Data>>
> & {
export type HookResult<S extends Schema, Data> = Awaited<ReturnType<SafeAction<S, Data>>> & {
fetchError?: string;
};

/**
* Type of hooks callbacks. These are executed when action is in a specific state.
*/
export type HookCallbacks<Schema extends z.ZodTypeAny, Data> = {
onExecute?: (input: z.input<Schema>) => MaybePromise<void>;
onSuccess?: (data: Data, input: z.input<Schema>, reset: () => void) => MaybePromise<void>;
export type HookCallbacks<S extends Schema, Data> = {
onExecute?: (input: InferIn<S>) => MaybePromise<void>;
onSuccess?: (data: Data, input: InferIn<S>, reset: () => void) => MaybePromise<void>;
onError?: (
error: Omit<HookResult<Schema, Data>, "data">,
input: z.input<Schema>,
error: Omit<HookResult<S, Data>, "data">,
input: InferIn<S>,
reset: () => void
) => MaybePromise<void>;
onSettled?: (
result: HookResult<Schema, Data>,
input: z.input<Schema>,
result: HookResult<S, Data>,
input: InferIn<S>,
reset: () => void
) => MaybePromise<void>;
};
Expand Down
Loading

0 comments on commit 93591fb

Please sign in to comment.