Skip to content

Commit

Permalink
feat: allow omitting schema argument in schema method (#102)
Browse files Browse the repository at this point in the history
This PR adds the ability to omit passing a validation schema to the `schema` method.
  • Loading branch information
TheEdoRan committed Apr 17, 2024
1 parent fccd480 commit aa11577
Show file tree
Hide file tree
Showing 11 changed files with 203 additions and 66 deletions.
@@ -0,0 +1,14 @@
"use server";

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

export const emptyAction = action
.metadata({ actionName: "onboardUser" })
.schema()
.action(async () => {
await new Promise((res) => setTimeout(res, 500));

return {
message: "Well done!",
};
});
56 changes: 56 additions & 0 deletions packages/example-app/src/app/(examples)/empty-schema/page.tsx
@@ -0,0 +1,56 @@
"use client";

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

export default function EmptySchema() {
const { execute, result, status, reset } = useAction(emptyAction, {
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 without schema</StyledHeading>
<form
className="flex flex-col mt-8 space-y-4"
onSubmit={(e) => {
e.preventDefault();
const formData = new FormData(e.currentTarget);

// Action call.
execute();
}}>
<StyledButton type="submit">Execute action</StyledButton>
<StyledButton type="button" onClick={reset}>
Reset
</StyledButton>
</form>
<ResultBox result={result} status={status} />
</main>
);
}
1 change: 1 addition & 0 deletions packages/example-app/src/app/page.tsx
Expand Up @@ -15,6 +15,7 @@ export default function Home() {
<span className="font-mono">useOptimisticAction</span> hook
</ExampleLink>
<ExampleLink href="/bind-arguments">Bind arguments</ExampleLink>
<ExampleLink href="/empty-schema">Empty schema</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
50 changes: 31 additions & 19 deletions packages/next-safe-action/src/hooks.ts
Expand Up @@ -19,7 +19,7 @@ const DEFAULT_RESULT = {

const getActionStatus = <
const ServerError,
const S extends Schema,
const S extends Schema | undefined,
const BAS extends readonly Schema[],
const FVE,
const FBAVE,
Expand Down Expand Up @@ -49,7 +49,7 @@ const getActionStatus = <

const useActionCallbacks = <
const ServerError,
const S extends Schema,
const S extends Schema | undefined,
const BAS extends readonly Schema[],
const FVE,
const FBAVE,
Expand All @@ -62,7 +62,7 @@ const useActionCallbacks = <
cb,
}: {
result: HookResult<ServerError, S, BAS, FVE, FBAVE, Data>;
input: InferIn<S>;
input: S extends Schema ? InferIn<S> : undefined;
status: HookActionStatus;
reset: () => void;
cb?: HookCallbacks<ServerError, S, BAS, FVE, FBAVE, Data>;
Expand Down Expand Up @@ -110,7 +110,7 @@ const useActionCallbacks = <
*/
export const useAction = <
const ServerError,
const S extends Schema,
const S extends Schema | undefined,
const BAS extends readonly Schema[],
const FVE,
const FBAVE,
Expand All @@ -122,18 +122,18 @@ export const useAction = <
const [, startTransition] = React.useTransition();
const [result, setResult] =
React.useState<HookResult<ServerError, S, BAS, FVE, FBAVE, Data>>(DEFAULT_RESULT);
const [input, setInput] = React.useState<InferIn<S>>();
const [input, setInput] = React.useState<S extends Schema ? InferIn<S> : void>();
const [isExecuting, setIsExecuting] = React.useState(false);

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

const execute = React.useCallback(
(input: InferIn<S>) => {
(input: S extends Schema ? InferIn<S> : void) => {
setInput(input);
setIsExecuting(true);

return startTransition(() => {
return safeActionFn(input)
return safeActionFn(input as S extends Schema ? InferIn<S> : undefined)
.then((res) => setResult(res ?? DEFAULT_RESULT))
.catch((e) => {
if (isRedirectError(e) || isNotFoundError(e)) {
Expand All @@ -154,7 +154,13 @@ export const useAction = <
setResult(DEFAULT_RESULT);
}, []);

useActionCallbacks({ result, input, status, reset, cb: callbacks });
useActionCallbacks({
result,
input: input as S extends Schema ? InferIn<S> : undefined,
status,
reset,
cb: callbacks,
});

return {
execute,
Expand All @@ -177,38 +183,38 @@ export const useAction = <
*/
export const useOptimisticAction = <
const ServerError,
const S extends Schema,
const S extends Schema | undefined,
const BAS extends readonly Schema[],
const FVE,
const FBAVE,
const Data,
>(
safeActionFn: HookSafeActionFn<ServerError, S, BAS, FVE, FBAVE, Data>,
initialOptimisticData: Data,
reducer: (state: Data, input: InferIn<S>) => Data,
reducer: (state: Data, input: S extends Schema ? InferIn<S> : undefined) => Data,
callbacks?: HookCallbacks<ServerError, S, BAS, FVE, FBAVE, Data>
) => {
const [, startTransition] = React.useTransition();
const [result, setResult] =
React.useState<HookResult<ServerError, S, BAS, FVE, FBAVE, Data>>(DEFAULT_RESULT);
const [input, setInput] = React.useState<InferIn<S>>();
const [input, setInput] = React.useState<S extends Schema ? InferIn<S> : void>();
const [isExecuting, setIsExecuting] = React.useState(false);

const [optimisticData, setOptimisticState] = React.useOptimistic<Data, InferIn<S>>(
initialOptimisticData,
reducer
);
const [optimisticData, setOptimisticState] = React.useOptimistic<
Data,
S extends Schema ? InferIn<S> : undefined
>(initialOptimisticData, reducer);

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

const execute = React.useCallback(
(input: InferIn<S>) => {
(input: S extends Schema ? InferIn<S> : void) => {
setInput(input);
setIsExecuting(true);

return startTransition(() => {
setOptimisticState(input);
return safeActionFn(input)
setOptimisticState(input as S extends Schema ? InferIn<S> : undefined);
return safeActionFn(input as S extends Schema ? InferIn<S> : undefined)
.then((res) => setResult(res ?? DEFAULT_RESULT))
.catch((e) => {
if (isRedirectError(e) || isNotFoundError(e)) {
Expand All @@ -229,7 +235,13 @@ export const useOptimisticAction = <
setResult(DEFAULT_RESULT);
}, []);

useActionCallbacks({ result, input, status, reset, cb: callbacks });
useActionCallbacks({
result,
input: input as S extends Schema ? InferIn<S> : undefined,
status,
reset,
cb: callbacks,
});

return {
execute,
Expand Down
22 changes: 14 additions & 8 deletions packages/next-safe-action/src/hooks.types.ts
Expand Up @@ -8,7 +8,7 @@ import type { MaybePromise } from "./utils";
*/
export type HookResult<
ServerError,
S extends Schema,
S extends Schema | undefined,
BAS extends readonly Schema[],
FVE,
FBAVE,
Expand All @@ -22,22 +22,26 @@ export type HookResult<
*/
export type HookCallbacks<
ServerError,
S extends Schema,
S extends Schema | undefined,
BAS extends readonly Schema[],
FVE,
FBAVE,
Data,
> = {
onExecute?: (args: { input: InferIn<S> }) => MaybePromise<void>;
onSuccess?: (args: { data: Data; input: InferIn<S>; reset: () => void }) => MaybePromise<void>;
onExecute?: (args: { input: S extends Schema ? InferIn<S> : undefined }) => MaybePromise<void>;
onSuccess?: (args: {
data: Data;
input: S extends Schema ? InferIn<S> : undefined;
reset: () => void;
}) => MaybePromise<void>;
onError?: (args: {
error: Omit<HookResult<ServerError, S, BAS, FVE, FBAVE, Data>, "data">;
input: InferIn<S>;
input: S extends Schema ? InferIn<S> : undefined;
reset: () => void;
}) => MaybePromise<void>;
onSettled?: (args: {
result: HookResult<ServerError, S, BAS, FVE, FBAVE, Data>;
input: InferIn<S>;
input: S extends Schema ? InferIn<S> : undefined;
reset: () => void;
}) => MaybePromise<void>;
};
Expand All @@ -48,12 +52,14 @@ export type HookCallbacks<
*/
export type HookSafeActionFn<
ServerError,
S extends Schema,
S extends Schema | undefined,
BAS extends readonly Schema[],
FVE,
FBAVE,
Data,
> = (clientInput: InferIn<S>) => Promise<SafeActionResult<ServerError, S, BAS, FVE, FBAVE, Data>>;
> = (
clientInput: S extends Schema ? InferIn<S> : undefined
) => Promise<SafeActionResult<ServerError, S, BAS, FVE, FBAVE, Data>>;

/**
* Type of the action status returned by `useAction` and `useOptimisticAction` hooks.
Expand Down
48 changes: 37 additions & 11 deletions packages/next-safe-action/src/index.ts
Expand Up @@ -87,8 +87,8 @@ class SafeActionClient<const ServerError, const Ctx = null, const Metadata = nul
this.#metadata = data;

return {
schema: <const S extends Schema, const FVE = ValidationErrors<S>>(
schema: S,
schema: <const S extends Schema | undefined = undefined, const FVE = ValidationErrors<S>>(
schema?: S,
utils?: {
formatValidationErrors?: FormatValidationErrorsFn<S, FVE>;
}
Expand All @@ -101,8 +101,12 @@ class SafeActionClient<const ServerError, const Ctx = null, const Metadata = nul
* @param schema An input schema supported by [TypeSchema](https://typeschema.com/#coverage).
* @returns {Function} The `define` function, which is used to define a new safe action.
*/
public schema<const S extends Schema, const FVE = ValidationErrors<S>, const MD = null>(
schema: S,
public schema<
const S extends Schema | undefined = undefined,
const FVE = ValidationErrors<S>,
const MD = null,
>(
schema?: S,
utils?: {
formatValidationErrors?: FormatValidationErrorsFn<S, FVE>;
}
Expand Down Expand Up @@ -134,13 +138,13 @@ class SafeActionClient<const ServerError, const Ctx = null, const Metadata = nul
}

#bindArgsSchemas<
const S extends Schema,
const S extends Schema | undefined,
const BAS extends readonly Schema[],
const FVE,
const FBAVE,
const MD = null,
>(args: {
mainSchema: S;
mainSchema?: S;
bindArgsSchemas: BAS;
formatValidationErrors?: FormatValidationErrorsFn<S, FVE>;
formatBindArgsValidationErrors?: FormatBindArgsValidationErrorsFn<BAS, FBAVE>;
Expand All @@ -163,14 +167,14 @@ class SafeActionClient<const ServerError, const Ctx = null, const Metadata = nul
* @returns {SafeActionFn}
*/
#action<
const S extends Schema,
const S extends Schema | undefined,
const BAS extends readonly Schema[],
const FVE,
const FBAVE = undefined,
const Data = null,
const MD = null,
>(args: {
schema: S;
schema?: S;
bindArgsSchemas: BAS;
serverCodeFn: ServerCodeFn<S, BAS, Data, Ctx, MD>;
formatValidationErrors?: FormatValidationErrorsFn<S, FVE>;
Expand All @@ -181,6 +185,14 @@ class SafeActionClient<const ServerError, const Ctx = null, const Metadata = nul
let frameworkError: Error | undefined = undefined;
const middlewareResult: MiddlewareResult<ServerError, any> = { success: false };

// If the number of bind args schemas + 1 (which is the optional main arg schema) is greater
// than the number of provided client inputs, it means that the main argument is missing.
// This happens when the main schema is missing (since it's optional), or if a void main schema
// is provided along with bind args schemas.
if (args.bindArgsSchemas.length + 1 > clientInputs.length) {
clientInputs.push(undefined);
}

// Execute the middleware stack.
const executeMiddlewareChain = async (idx = 0) => {
const currentFn = this.#middlewareFns[idx];
Expand All @@ -204,8 +216,22 @@ class SafeActionClient<const ServerError, const Ctx = null, const Metadata = nul
// Validate the client inputs in parallel.
const parsedInputs = await Promise.all(
clientInputs.map((input, i) => {
const s = i === clientInputs.length - 1 ? args.schema : args.bindArgsSchemas[i]!;
return validate(s, input);
// 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") {
return {
success: true,
data: undefined,
} as const;
}

// Otherwise, parse input with the schema.
return validate(args.schema, input);
}

// Otherwise, we're processing bind args client inputs.
return validate(args.bindArgsSchemas[i]!, input);
})
);

Expand Down Expand Up @@ -256,7 +282,7 @@ class SafeActionClient<const ServerError, const Ctx = null, const Metadata = nul

const data =
(await args.serverCodeFn({
parsedInput: parsedInputDatas.at(-1) as Infer<S>,
parsedInput: parsedInputDatas.at(-1) as S extends Schema ? Infer<S> : undefined,
bindArgsParsedInputs: parsedInputDatas.slice(0, -1) as InferArray<BAS>,
ctx: prevCtx,
metadata: this.#metadata as any as MD,
Expand Down

0 comments on commit aa11577

Please sign in to comment.