Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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 = () =>
Expand Down Expand Up @@ -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 = () => (
<Action onAction={asyncAction2}>
<ActionBatch>
<Action onAction={asyncAction1}>
<TestButton />
</Action>
</ActionBatch>
</Action>
);

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", () => {
Expand Down
18 changes: 18 additions & 0 deletions packages/components/src/components/Action/ActionBatch.tsx
Original file line number Diff line number Diff line change
@@ -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<ActionBatchProps> = (props) => {
const { children } = props;

return <Action>{children}</Action>;
};

export default ActionBatch;
1 change: 1 addition & 0 deletions packages/components/src/components/Action/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
export { Action } from "./Action";
export { ActionBatch } from "./ActionBatch";
export * from "./types";
export { default } from "./Action";
export { useAriaAnnounceSuspense } from "./lib/ariaLive";
1 change: 0 additions & 1 deletion packages/components/src/components/Button/Button.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,6 @@ const disablePendingProps = (props: ButtonProps) => {
props.onPressUp = undefined;
props.onKeyDown = undefined;
props.onKeyUp = undefined;
props.type = "button";
}

return props;
Expand Down
1 change: 1 addition & 0 deletions packages/components/src/index/flr-universal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ export * from "@/components/Icon/components/icons";

export {
Action,
ActionBatch,
type ActionFn,
type ActionProps,
BrowserOnly,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import Action from "@/components/Action";
import Button from "@/components/Button";
import TextField, { type TextFieldProps } from "@/components/TextField";
import {
Expand Down Expand Up @@ -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<object>();
Expand All @@ -122,7 +124,9 @@ describe("submission", () => {
<Field name="test">
<TextField placeholder="textfield" aria-label="test" />
</Field>
<SubmitButton data-testid="submit-button">Submit</SubmitButton>
<Action onAction={onAfterSubmitAction}>
<SubmitButton data-testid="submit-button">Submit</SubmitButton>
</Action>
</Form>
);
};
Expand All @@ -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(<TestForm />);
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", () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,17 +7,15 @@ 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<F extends FieldValues> {
form: UseFormReturn<F>;
id: string;
isReadOnly: boolean;
setReadOnly: Dispatch<SetStateAction<boolean>>;
formSubmitAction: ActionModel;
onAfterSuccessFeedback?: AfterFormSubmitCallback;
}

export const FormContext = createContext<FormContext<FieldValues> | undefined>(
Expand All @@ -42,20 +40,14 @@ export const FormContextProvider = (props: FormContextProviderProps) => {
const [isReadOnlyState, setReadOnly] = useState(isReadOnlyProp);
const isReadOnly = isReadOnlyProp || isReadOnlyState;

const formSubmitAction = useFormSubmitAction({
form,
setReadOnly,
onAfterSuccessFeedback,
});

return (
<FormContext
value={{
isReadOnly,
setReadOnly,
id,
form,
formSubmitAction,
onAfterSuccessFeedback,
}}
>
{children}
Expand Down
Original file line number Diff line number Diff line change
@@ -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<void>());

const formSubmitAction = ActionModel.useNew({
onAction: () => submitPromise.promise,
});

const { isSubmitting, isSubmitted, isSubmitSuccessful } = form.formState;
const wasSubmitting = useRef(isSubmitting);
Expand All @@ -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);
}
Expand All @@ -37,9 +39,8 @@ export const useFormSubmitAction = (options: Options) => {
isSubmitting,
isSubmitted,
isSubmitSuccessful,
formSubmitAction,
setReadOnly,
onAfterSuccessFeedback,
submitPromise,
]);

return formSubmitAction;
Expand Down
Original file line number Diff line number Diff line change
@@ -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<PropsWithChildren> = (props) => {
const { children } = props;

const { form, setReadOnly } = useFormContext();

const formSubmitAction = useFormSubmitAction({
form,
setReadOnly,
});

return <Action actionModel={formSubmitAction}>{children}</Action>;
};

export const FormSubmitAction: FC<PropsWithChildren> = (props) => {
const { children } = props;
const action = useFormContext().formSubmitAction;
return <Action actionModel={action}>{children}</Action>;

const { onAfterSuccessFeedback } = useFormContext();

return (
<ActionBatch>
<Action onAction={onAfterSuccessFeedback}>
<ActionBatch>
<InnerFormSubmitAction>{children}</InnerFormSubmitAction>
</ActionBatch>
</Action>
</ActionBatch>
);
};

export default FormSubmitAction;
Loading