From 50d8c39d88ed80e6f32600c99d80b861f1e246cc Mon Sep 17 00:00:00 2001 From: Marco Falkenberg Date: Tue, 10 Feb 2026 15:02:14 +0100 Subject: [PATCH] feat: support nesting of submit actions --- .../components/Action/Action.browser.test.tsx | 56 ++++++++++++++++++- .../src/components/Action/ActionBatch.tsx | 18 ++++++ .../components/src/components/Action/index.ts | 1 + .../src/components/Button/Button.tsx | 1 - .../components/src/index/flr-universal.ts | 1 + .../components/Form/Form.browser.test.tsx | 18 +++++- .../FormContextProvider.tsx | 12 +--- .../useFormSubmitAction.ts | 19 ++++--- .../FormSubmitAction/FormSubmitAction.tsx | 30 +++++++++- 9 files changed, 130 insertions(+), 26 deletions(-) create mode 100644 packages/components/src/components/Action/ActionBatch.tsx diff --git a/packages/components/src/components/Action/Action.browser.test.tsx b/packages/components/src/components/Action/Action.browser.test.tsx index 0392093e80..333b961919 100644 --- a/packages/components/src/components/Action/Action.browser.test.tsx +++ b/packages/components/src/components/Action/Action.browser.test.tsx @@ -1,13 +1,14 @@ import { render } from "vitest-browser-react"; import { page, userEvent } from "vitest/browser"; import { type FC } from "react"; -import Action, { type ActionProps } from "@/components/Action"; +import Action, { ActionBatch, type ActionProps } from "@/components/Action"; import { Button, type ButtonProps } from "@/components/Button"; import type { Mock } from "vitest"; import Content from "@/components/Content/Content"; import ActionGroup from "@/components/ActionGroup/ActionGroup"; import Heading from "@/components/Heading/Heading"; import Modal from "@/components/Modal"; +import { duration } from "@/components/Action/models/ActionState"; const asyncActionDuration = 700; const sleep = () => @@ -413,6 +414,59 @@ describe("Feedback", () => { await rerender(); expectNoIconInDom(); }); + + test("can be splitted by batches", async () => { + asyncAction1.mockImplementation(async () => { + await sleep(); + await sleep(); + }); + + asyncAction2.mockImplementation(async () => { + await sleep(); + await sleep(); + }); + + const ui = () => ( + + + + + + + + ); + + const { rerender } = await render(ui()); + expectNoIconInDom(); + + await clickTrigger(); + + // First batch + await vitest.advanceTimersByTimeAsync(duration.pending); + await rerender(ui()); + expectIconInDom("loader-2"); + + // First batch done + await vitest.advanceTimersByTimeAsync( + asyncActionDuration * 2 - duration.pending, + ); + await rerender(ui()); + expectIconInDom("check"); + + // Second batch + await vitest.advanceTimersByTimeAsync( + duration.succeeded + duration.pending, + ); + await rerender(ui()); + expectIconInDom("loader-2"); + + // Second batch done + await vitest.advanceTimersByTimeAsync( + asyncActionDuration * 2 - duration.pending, + ); + await rerender(ui()); + expectIconInDom("check"); + }); }); describe("Pending state", () => { diff --git a/packages/components/src/components/Action/ActionBatch.tsx b/packages/components/src/components/Action/ActionBatch.tsx new file mode 100644 index 0000000000..bfd16d8c12 --- /dev/null +++ b/packages/components/src/components/Action/ActionBatch.tsx @@ -0,0 +1,18 @@ +import Action from "@/components/Action/Action"; +import type { FC, PropsWithChildren } from "react"; + +export type ActionBatchProps = PropsWithChildren; + +/** + * Batches multiple actions together and shows feedback when all actions have + * completed. + * + * By default async actions are automatically batched. + */ +export const ActionBatch: FC = (props) => { + const { children } = props; + + return {children}; +}; + +export default ActionBatch; diff --git a/packages/components/src/components/Action/index.ts b/packages/components/src/components/Action/index.ts index 4a19b25285..150444a3f9 100644 --- a/packages/components/src/components/Action/index.ts +++ b/packages/components/src/components/Action/index.ts @@ -1,4 +1,5 @@ export { Action } from "./Action"; +export { ActionBatch } from "./ActionBatch"; export * from "./types"; export { default } from "./Action"; export { useAriaAnnounceSuspense } from "./lib/ariaLive"; diff --git a/packages/components/src/components/Button/Button.tsx b/packages/components/src/components/Button/Button.tsx index 4734a8f15c..ba61441e30 100644 --- a/packages/components/src/components/Button/Button.tsx +++ b/packages/components/src/components/Button/Button.tsx @@ -57,7 +57,6 @@ const disablePendingProps = (props: ButtonProps) => { props.onPressUp = undefined; props.onKeyDown = undefined; props.onKeyUp = undefined; - props.type = "button"; } return props; diff --git a/packages/components/src/index/flr-universal.ts b/packages/components/src/index/flr-universal.ts index 50740516f9..37c9d625a6 100644 --- a/packages/components/src/index/flr-universal.ts +++ b/packages/components/src/index/flr-universal.ts @@ -2,6 +2,7 @@ export * from "@/components/Icon/components/icons"; export { Action, + ActionBatch, type ActionFn, type ActionProps, BrowserOnly, diff --git a/packages/components/src/integrations/react-hook-form/components/Form/Form.browser.test.tsx b/packages/components/src/integrations/react-hook-form/components/Form/Form.browser.test.tsx index ae3da234ae..8a6ac91a89 100644 --- a/packages/components/src/integrations/react-hook-form/components/Form/Form.browser.test.tsx +++ b/packages/components/src/integrations/react-hook-form/components/Form/Form.browser.test.tsx @@ -1,3 +1,4 @@ +import Action from "@/components/Action"; import Button from "@/components/Button"; import TextField, { type TextFieldProps } from "@/components/TextField"; import { @@ -113,7 +114,8 @@ describe("resetting", () => { describe("submission", () => { const onAfterSubmit = vitest.fn(); - const onSubmit = vitest.fn(() => onAfterSubmit); + const onAfterSubmitAction = vitest.fn(); + const onSubmit = vitest.fn(async () => onAfterSubmit); const TestForm: FC = () => { const form = useForm(); @@ -122,7 +124,9 @@ describe("submission", () => { - Submit + + Submit + ); }; @@ -149,6 +153,16 @@ describe("submission", () => { await vitest.advanceTimersByTimeAsync(1000); expect(onAfterSubmit).toHaveBeenCalled(); }); + + test("parent action of submit button is called after successful submission", async () => { + await render(); + const submitButton = page.getByTestId("submit-button"); + await userEvent.click(submitButton); + await vitest.advanceTimersByTimeAsync(500); + expect(onAfterSubmitAction).not.toHaveBeenCalled(); + await vitest.advanceTimersByTimeAsync(1000); + expect(onAfterSubmitAction).toHaveBeenCalled(); + }); }); describe("readonly", () => { diff --git a/packages/components/src/integrations/react-hook-form/components/FormContextProvider/FormContextProvider.tsx b/packages/components/src/integrations/react-hook-form/components/FormContextProvider/FormContextProvider.tsx index e85a61e81b..b04a28b06f 100644 --- a/packages/components/src/integrations/react-hook-form/components/FormContextProvider/FormContextProvider.tsx +++ b/packages/components/src/integrations/react-hook-form/components/FormContextProvider/FormContextProvider.tsx @@ -7,9 +7,7 @@ import { type PropsWithChildren, type SetStateAction, } from "react"; -import type { ActionModel } from "@/components/Action/models/ActionModel"; import invariant from "invariant"; -import { useFormSubmitAction } from "@/integrations/react-hook-form/components/FormContextProvider/useFormSubmitAction"; import type { AfterFormSubmitCallback } from "@/integrations/react-hook-form/components/Form/Form"; interface FormContext { @@ -17,7 +15,7 @@ interface FormContext { id: string; isReadOnly: boolean; setReadOnly: Dispatch>; - formSubmitAction: ActionModel; + onAfterSuccessFeedback?: AfterFormSubmitCallback; } export const FormContext = createContext | undefined>( @@ -42,12 +40,6 @@ export const FormContextProvider = (props: FormContextProviderProps) => { const [isReadOnlyState, setReadOnly] = useState(isReadOnlyProp); const isReadOnly = isReadOnlyProp || isReadOnlyState; - const formSubmitAction = useFormSubmitAction({ - form, - setReadOnly, - onAfterSuccessFeedback, - }); - return ( { setReadOnly, id, form, - formSubmitAction, + onAfterSuccessFeedback, }} > {children} diff --git a/packages/components/src/integrations/react-hook-form/components/FormContextProvider/useFormSubmitAction.ts b/packages/components/src/integrations/react-hook-form/components/FormContextProvider/useFormSubmitAction.ts index 0f9e1b2f4f..73470a39b8 100644 --- a/packages/components/src/integrations/react-hook-form/components/FormContextProvider/useFormSubmitAction.ts +++ b/packages/components/src/integrations/react-hook-form/components/FormContextProvider/useFormSubmitAction.ts @@ -1,18 +1,21 @@ import { ActionModel } from "@/components/Action/models/ActionModel"; -import type { AfterFormSubmitCallback } from "@/integrations/react-hook-form/components/Form/Form"; +import { useStatic } from "@/lib/hooks/useStatic"; import { useEffect, useRef } from "react"; import type { UseFormReturn } from "react-hook-form"; interface Options { form: UseFormReturn; setReadOnly: (isReadOnly: boolean) => void; - onAfterSuccessFeedback?: AfterFormSubmitCallback; } export const useFormSubmitAction = (options: Options) => { - const { form, setReadOnly, onAfterSuccessFeedback } = options; + const { form, setReadOnly } = options; - const formSubmitAction = ActionModel.useNew({}); + const submitPromise = useStatic(() => Promise.withResolvers()); + + const formSubmitAction = ActionModel.useNew({ + onAction: () => submitPromise.promise, + }); const { isSubmitting, isSubmitted, isSubmitSuccessful } = form.formState; const wasSubmitting = useRef(isSubmitting); @@ -23,12 +26,11 @@ export const useFormSubmitAction = (options: Options) => { if (isSubmitting) { setReadOnly(true); - formSubmitAction.state.onAsyncStart(); } else if (submittingDone) { if (isSubmitSuccessful) { - formSubmitAction.state.onSucceeded().then(onAfterSuccessFeedback); + submitPromise.resolve(); } else { - formSubmitAction.state.onFailed(new Error("Form submission failed")); + submitPromise.reject(new Error("Form submission failed")); } setReadOnly(false); } @@ -37,9 +39,8 @@ export const useFormSubmitAction = (options: Options) => { isSubmitting, isSubmitted, isSubmitSuccessful, - formSubmitAction, setReadOnly, - onAfterSuccessFeedback, + submitPromise, ]); return formSubmitAction; diff --git a/packages/components/src/integrations/react-hook-form/components/FormSubmitAction/FormSubmitAction.tsx b/packages/components/src/integrations/react-hook-form/components/FormSubmitAction/FormSubmitAction.tsx index e4dbcc7954..576c9d7885 100644 --- a/packages/components/src/integrations/react-hook-form/components/FormSubmitAction/FormSubmitAction.tsx +++ b/packages/components/src/integrations/react-hook-form/components/FormSubmitAction/FormSubmitAction.tsx @@ -1,11 +1,35 @@ -import Action from "@/components/Action"; +import Action, { ActionBatch } from "@/components/Action"; import { useFormContext } from "@/integrations/react-hook-form/components/FormContextProvider"; +import { useFormSubmitAction } from "@/integrations/react-hook-form/components/FormContextProvider/useFormSubmitAction"; import type { FC, PropsWithChildren } from "react"; +const InnerFormSubmitAction: FC = (props) => { + const { children } = props; + + const { form, setReadOnly } = useFormContext(); + + const formSubmitAction = useFormSubmitAction({ + form, + setReadOnly, + }); + + return {children}; +}; + export const FormSubmitAction: FC = (props) => { const { children } = props; - const action = useFormContext().formSubmitAction; - return {children}; + + const { onAfterSuccessFeedback } = useFormContext(); + + return ( + + + + {children} + + + + ); }; export default FormSubmitAction;