From ec65067d04a72a51b71b63d3b4569843b8fd0dad Mon Sep 17 00:00:00 2001 From: clover caruso Date: Wed, 11 Mar 2026 15:27:52 -0700 Subject: [PATCH] feat: pass `args={null}` to disable a mutation button resolves #14 --- src/react.ts | 41 ++++++++++++++++++++++-------------- test/useMutate.test.tsx | 46 ++++++++++++++++++++++++++++++++++++++++- 2 files changed, 70 insertions(+), 17 deletions(-) diff --git a/src/react.ts b/src/react.ts index 3307dd5d5a9aaf5c997a25cab38cfdc1bf2892af..78f247a11f982a7d72bb1f63282fc03991ef10b3 100644 --- a/src/react.ts +++ b/src/react.ts @@ -150,7 +150,7 @@ class Observer { unsubscribe: (() => void) | null = null; currentKey: string | null = null; pendingTimer: Timer | null = null; - debounced: boolean; + debounced: boolean = false; constructor(setRerender: (fn: number) => void) { this.setRerender = setRerender; @@ -236,7 +236,7 @@ class Observer { isSuccess: hasResult && !hasError, isError: hasError, isOptimisticData: status === "waiting" || status === "mutating" - || status === "refetching" || (hasError && status !== "idle"), + || status === "refetching", args: hasError || hasResult ? undefined : this.state.args, }); @@ -317,7 +317,7 @@ class Observer { isSuccess: hasResult && !hasError, isError: hasError, isOptimisticData: status === "waiting" || status === "mutating" - || status === "refetching" || (hasError && status !== "idle"), + || status === "refetching", args: hasError || hasResult ? undefined : this.state.args, }); }, @@ -420,14 +420,17 @@ export interface MutationButtonProps { | Mutation | UseMutateResult; /** Preventing default will interrupt the mutation */ - args: Args | ((e: MouseEvent) => Args | null); + args: Args | null | ((e: MouseEvent) => Args | null); /** Preventing default will interrupt the mutation */ onClick?: (e: MouseEvent) => void; + disabled?: boolean; /** Omitting this will use the global error handler */ onError?: (result: unknown) => void; /** Omitting this will use the global success handler */ - onSuccess?: (result: Result) => void; + onSuccessUi?: (result: Result) => void; + /** Does not prevent the global handler */ + onSuccessData?: (result: Result) => void; /** Global event handlers will still be called! */ onSettled?: ( @@ -471,6 +474,7 @@ type Flatten = Identity<{ [K in keyof T]: T[K] }>; type ResolveMutationButtonFc = FC< & Omit> & BaseButtonProps + & { disabled?: boolean } >; function GenericMutationButton< @@ -485,8 +489,10 @@ function GenericMutationButton< mutation, args, onClick, + disabled: disabledAttr, onError, - onSuccess, + onSuccessUi, + onSuccessData, onSettled, ...forwarded } = props; @@ -494,22 +500,25 @@ function GenericMutationButton< const localHook = useMutate("subscribe" in mutation ? mutation : null); const state = "subscribe" in mutation ? localHook : mutation; + const disabled = disabledAttr || args == null || state.isDisabled; + const handleClick = useCallback((e: MouseEvent) => { + onClick?.(e); + if (e.defaultPrevented) return; + const computedArgs = typeof args === "function" ? args(e) : args; + if (!computedArgs || e.defaultPrevented) return; + state.runWithOptions( + ...computedArgs, + { onSuccessUi, onSuccessData, onError, onSettled }, + ); + }, [args, onClick, onError, onSettled, onSuccessUi, onSuccessData, state]); // NOTE: the JSR has trouble with JSX syntax for some reason. return jsx( Component, { ...forwarded, - onClick: useCallback((e: MouseEvent) => { - onClick?.(e); - if (e.defaultPrevented) return; - const computedArgs = typeof args === "function" ? args(e) : args; - if (!computedArgs || e.defaultPrevented) return; - state.runWithOptions( - ...computedArgs, - { onSuccess, onError, onSettled }, - ); - }, [state]), + disabled, + onClick: disabled ? undefined : handleClick, isPending: state.isPending, } satisfies Parameters[0], ); diff --git a/test/useMutate.test.tsx b/test/useMutate.test.tsx index dec12af6d80142abf5406c915904daf37c2373c9..3a1f4a63a48d14d0f9be89338aba0451bfbd1137 100644 --- a/test/useMutate.test.tsx +++ b/test/useMutate.test.tsx @@ -1,8 +1,9 @@ import { assertEquals } from "@std/assert"; import { act, render, screen } from "@testing-library/react"; import { userEvent } from "@testing-library/user-event"; +import type { FC, MouseEventHandler, ReactNode } from "react"; import { test, vi } from "vitest"; -import { useMutate } from "../src/react.ts"; +import { createMutationButton, useMutate } from "../src/react.ts"; import { createTestMutationClient, IterableStream } from "./share.ts"; test("useMutate - global error and success handling", async () => { @@ -274,3 +275,46 @@ test("useMutate - local error and success handling", async () => { assertEquals(successMessages, []); assertEquals(errorMessages, []); }); + +test("MutationButton should allow args={null} to disable mutation runs", async () => { + const user = userEvent.setup({ delay: null }); + const { client } = createTestMutationClient(); + const mutate = vi.fn(async (value: number) => value + 1); + + const mutTest = client.define({ + mutate, + describe: "Test the action", + describeResult: "Tested the action", + optimistic: () => {}, + }); + + const MutationButtonBase: FC<{ + children?: ReactNode; + disabled?: boolean; + isPending: boolean; + onClick: MouseEventHandler | undefined; + }> = function MutationButtonBase({ + children, + disabled, + isPending, + onClick, + }) { + return ( + + ); + }; + const MutationButton = createMutationButton(MutationButtonBase); + + render( + + button + , + ); + + assertEquals((screen.getByTestId("a") as HTMLButtonElement).disabled, true); + await act(() => user.click(screen.getByTestId("a"))); + + assertEquals(mutate.mock.calls, []); +}); -- 2.54.0