Skip to content

Commit

Permalink
feat: support binding additional arguments (#97)
Browse files Browse the repository at this point in the history
This PR adds the support for binding additional arguments to the safe action.
  • Loading branch information
TheEdoRan authored Apr 8, 2024
1 parent f36392a commit f628dc7
Show file tree
Hide file tree
Showing 26 changed files with 542 additions and 218 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
"use server";

import { action } from "@/lib/safe-action";
import { z } from "zod";

const schema = z.object({
username: z.string().min(3).max(30),
});

export const onboardUser = action
.metadata({ actionName: "onboardUser" })
.schema(schema)
.bindArgsSchemas<[userId: z.ZodString, age: z.ZodNumber]>([
z.string().uuid(),
z.number().min(18).max(150),
])
.define(
async ({
parsedInput: { username },
bindArgsParsedInputs: [userId, age],
}) => {
await new Promise((res) => setTimeout(res, 1000));

return {
message: `Welcome on board, ${username}! (age = ${age}, user id = ${userId})`,
};
}
);
72 changes: 72 additions & 0 deletions packages/example-app/src/app/(examples)/bind-arguments/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
"use client";

import { StyledButton } from "@/app/_components/styled-button";
import { StyledHeading } from "@/app/_components/styled-heading";
import { StyledInput } from "@/app/_components/styled-input";
import { useAction } from "next-safe-action/hooks";
import { ResultBox } from "../../_components/result-box";
import { onboardUser } from "./onboard-action";

export default function BindArguments() {
const boundOnboardUser = onboardUser.bind(
null,
crypto.randomUUID(),
Math.floor(Math.random() * 200)
);

const { execute, result, status, reset } = useAction(boundOnboardUser, {
onSuccess(data, input, reset) {
console.log("HELLO FROM ONSUCCESS", data, input);

// You can reset result object by calling `reset`.
// reset();
},
onError(error, input, reset) {
console.log("OH NO FROM ONERROR", error, input);

// You can reset result object by calling `reset`.
// reset();
},
onSettled(result, input, reset) {
console.log("HELLO FROM ONSETTLED", result, input);

// You can reset result object by calling `reset`.
// reset();
},
onExecute(input) {
console.log("HELLO FROM ONEXECUTE", input);
},
});

console.log("status:", status);

return (
<main className="w-96 max-w-full px-4">
<StyledHeading>Action binding arguments</StyledHeading>
<form
className="flex flex-col mt-8 space-y-4"
onSubmit={(e) => {
e.preventDefault();
const formData = new FormData(e.currentTarget);
const input = Object.fromEntries(formData) as {
username: string;
};

// Action call.
execute(input);
}}>
<StyledInput
type="text"
name="username"
id="username"
placeholder="Username"
/>
<StyledButton type="submit">Onboard user</StyledButton>
<StyledButton type="button" onClick={reset}>
Reset
</StyledButton>
</form>
<ResultBox result={result} status={status} />
</main>
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ const schema = z.object({
export const loginUser = action
.metadata({ actionName: "loginUser" })
.schema(schema)
.define(async ({ username, password }, ctx) => {
.define(async ({ parsedInput: { username, password } }) => {
if (username === "johndoe") {
returnValidationErrors(schema, {
username: {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ const schema = z.object({
export const deleteUser = action
.metadata({ actionName: "deleteUser" })
.schema(schema)
.define(async ({ userId }) => {
.define(async ({ parsedInput: { userId } }) => {
await new Promise((res) => setTimeout(res, 1000));

if (Math.random() > 0.5) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ const schema = z.object({
export const addLikes = action
.metadata({ actionName: "addLikes" })
.schema(schema)
.define(async ({ incrementBy }) => {
.define(async ({ parsedInput: { incrementBy } }) => {
await new Promise((res) => setTimeout(res, 2000));

const likesCount = incrementLikes(incrementBy);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import { schema } from "./validation";
export const buyProduct = action
.metadata({ actionName: "buyProduct" })
.schema(schema)
.define(async ({ productId }) => {
.define(async ({ parsedInput: { productId } }) => {
return {
productId,
transactionId: randomUUID(),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ const schema = zfd.formData({
export const signup = action
.metadata({ actionName: "signup" })
.schema(schema)
.define(async ({ email, password }) => {
.define(async ({ parsedInput: { email, password } }) => {
console.log("Email:", email, "Password:", password);
return {
success: true,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,8 @@ export const editUser = authAction
.define(
// Here you have access to `userId`, and `sessionId which comes from middleware functions
// defined before.
// \\\\\\\\\\\\\\\\\\
async ({ fullName, age }, { ctx: { userId, sessionId } }) => {
// \\\\\\\\\\\\\\\\\\
async ({ parsedInput: { fullName, age }, ctx: { userId, sessionId } }) => {
if (fullName.toLowerCase() === "john doe") {
return {
error: {
Expand Down
1 change: 1 addition & 0 deletions packages/example-app/src/app/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ export default function Home() {
<ExampleLink href="/optimistic-hook">
<span className="font-mono">useOptimisticAction</span> hook
</ExampleLink>
<ExampleLink href="/bind-arguments">Bind arguments</ExampleLink>
<ExampleLink href="/server-form">Server Form</ExampleLink>
<ExampleLink href="/client-form">Client Form</ExampleLink>
<ExampleLink href="/react-hook-form">React Hook Form</ExampleLink>
Expand Down
20 changes: 9 additions & 11 deletions packages/example-app/src/lib/safe-action.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ export const action = createSafeActionClient({
// Otherwise return default error message.
return DEFAULT_SERVER_ERROR_MESSAGE;
},
}).use(async ({ next, metadata }) => {
}).use(async ({ next, metadata, clientInput, bindArgsClientInputs }) => {
// Here we use a logging middleware.
const start = Date.now();

Expand All @@ -34,18 +34,16 @@ export const action = createSafeActionClient({

const end = Date.now();

// Log the execution time of the action.
console.log(
"LOGGING MIDDLEWARE: this action took",
end - start,
"ms to execute"
);
const durationInMs = end - start;

// Log the result
console.log("LOGGING MIDDLEWARE: result ->", result);
const logObject: Record<string, any> = { durationInMs };

// Log metadata
console.log("LOGGING MIDDLEWARE: metadata ->", metadata);
logObject.clientInput = clientInput;
logObject.bindArgsClientInputs = bindArgsClientInputs;
logObject.metadata = metadata;
logObject.result = result;

console.log("MIDDLEWARE LOG:", logObject);

// And then return the result of the awaited next middleware.
return result;
Expand Down
60 changes: 40 additions & 20 deletions packages/next-safe-action/src/hooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,7 @@ import { isNotFoundError } from "next/dist/client/components/not-found.js";
import { isRedirectError } from "next/dist/client/components/redirect.js";
import * as React from "react";
import {} from "react/experimental";
import type { HookActionStatus, HookCallbacks, HookResult } from "./hooks.types";
import type { SafeActionFn } from "./index.types";
import type { HookActionStatus, HookCallbacks, HookResult, HookSafeActionFn } from "./hooks.types";
import { isError } from "./utils";

// UTILS
Expand All @@ -16,18 +15,24 @@ const DEFAULT_RESULT = {
fetchError: undefined,
serverError: undefined,
validationErrors: undefined,
} satisfies HookResult<any, any, any>;

const getActionStatus = <const ServerError, const S extends Schema, const Data>(
} satisfies HookResult<any, any, any, any>;

const getActionStatus = <
const ServerError,
const S extends Schema,
const BAS extends Schema[],
const Data,
>(
isExecuting: boolean,
result: HookResult<ServerError, S, Data>
result: HookResult<ServerError, S, BAS, Data>
): HookActionStatus => {
if (isExecuting) {
return "executing";
} else if (typeof result.data !== "undefined") {
return "hasSucceeded";
} else if (
typeof result.validationErrors !== "undefined" ||
typeof result.bindArgsValidationErrors !== "undefined" ||
typeof result.serverError !== "undefined" ||
typeof result.fetchError !== "undefined"
) {
Expand All @@ -37,12 +42,17 @@ const getActionStatus = <const ServerError, const S extends Schema, const Data>(
return "idle";
};

const useActionCallbacks = <const ServerError, const S extends Schema, const Data>(
result: HookResult<ServerError, S, Data>,
const useActionCallbacks = <
const ServerError,
const S extends Schema,
const BAS extends Schema[],
const Data,
>(
result: HookResult<ServerError, S, BAS, Data>,
input: InferIn<S>,
status: HookActionStatus,
reset: () => void,
cb?: HookCallbacks<ServerError, S, Data>
cb?: HookCallbacks<ServerError, S, BAS, Data>
) => {
const onExecuteRef = React.useRef(cb?.onExecute);
const onSuccessRef = React.useRef(cb?.onSuccess);
Expand Down Expand Up @@ -85,16 +95,21 @@ const useActionCallbacks = <const ServerError, const S extends Schema, const Dat
*
* {@link https://next-safe-action.dev/docs/usage/client-components/hooks/useaction See an example}
*/
export const useAction = <const ServerError, const S extends Schema, const Data>(
safeActionFn: SafeActionFn<ServerError, S, Data>,
callbacks?: HookCallbacks<ServerError, S, Data>
export const useAction = <
const ServerError,
const S extends Schema,
const BAS extends Schema[],
const Data,
>(
safeActionFn: HookSafeActionFn<ServerError, S, BAS, Data>,
callbacks?: HookCallbacks<ServerError, S, BAS, Data>
) => {
const [, startTransition] = React.useTransition();
const [result, setResult] = React.useState<HookResult<ServerError, S, Data>>(DEFAULT_RESULT);
const [result, setResult] = React.useState<HookResult<ServerError, S, BAS, Data>>(DEFAULT_RESULT);
const [input, setInput] = React.useState<InferIn<S>>();
const [isExecuting, setIsExecuting] = React.useState(false);

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

const execute = React.useCallback(
(input: InferIn<S>) => {
Expand Down Expand Up @@ -144,14 +159,19 @@ export const useAction = <const ServerError, const S extends Schema, const Data>
*
* {@link https://next-safe-action.dev/docs/usage/client-components/hooks/useoptimisticaction See an example}
*/
export const useOptimisticAction = <const ServerError, const S extends Schema, const Data>(
safeActionFn: SafeActionFn<ServerError, S, Data>,
export const useOptimisticAction = <
const ServerError,
const S extends Schema,
const BAS extends Schema[],
const Data,
>(
safeActionFn: HookSafeActionFn<ServerError, S, BAS, Data>,
initialOptimisticData: Data,
reducer: (state: Data, input: InferIn<S>) => Data,
callbacks?: HookCallbacks<ServerError, S, Data>
callbacks?: HookCallbacks<ServerError, S, BAS, Data>
) => {
const [, startTransition] = React.useTransition();
const [result, setResult] = React.useState<HookResult<ServerError, S, Data>>(DEFAULT_RESULT);
const [result, setResult] = React.useState<HookResult<ServerError, S, BAS, Data>>(DEFAULT_RESULT);
const [input, setInput] = React.useState<InferIn<S>>();
const [isExecuting, setIsExecuting] = React.useState(false);

Expand All @@ -160,7 +180,7 @@ export const useOptimisticAction = <const ServerError, const S extends Schema, c
reducer
);

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

const execute = React.useCallback(
(input: InferIn<S>) => {
Expand Down Expand Up @@ -201,4 +221,4 @@ export const useOptimisticAction = <const ServerError, const S extends Schema, c
};
};

export type { HookActionStatus, HookCallbacks, HookResult };
export type { HookActionStatus, HookCallbacks, HookResult, HookSafeActionFn };
23 changes: 16 additions & 7 deletions packages/next-safe-action/src/hooks.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,32 +5,41 @@ import type { MaybePromise } from "./utils";
/**
* Type of `result` object returned by `useAction` and `useOptimisticAction` hooks.
*/
export type HookResult<ServerError, S extends Schema, Data> = SafeActionResult<
export type HookResult<
ServerError,
S,
Data
> & {
S extends Schema,
BAS extends Schema[],
Data,
> = SafeActionResult<ServerError, S, BAS, Data> & {
fetchError?: string;
};

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

/**
* Type of the safe action function passed to hooks. Same as `SafeActionFn` except it accepts
* just a single input, without bind arguments.
*/
export type HookSafeActionFn<ServerError, S extends Schema, BAS extends Schema[], Data> = (
clientInput: InferIn<S>
) => Promise<SafeActionResult<ServerError, S, BAS, Data>>;

/**
* Type of the action status returned by `useAction` and `useOptimisticAction` hooks.
*/
Expand Down
Loading

0 comments on commit f628dc7

Please sign in to comment.