Skip to content

Commit

Permalink
feat!(Form): show Action state in submit button
Browse files Browse the repository at this point in the history
  • Loading branch information
mfal committed Jun 10, 2024
1 parent 7d8145a commit d4b0dbd
Show file tree
Hide file tree
Showing 11 changed files with 138 additions and 47 deletions.
5 changes: 3 additions & 2 deletions packages/components/src/components/Action/Action.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -39,8 +39,9 @@ const actionButtonContext: ComponentPropsContext<"Button"> = {
};

export const Action: FC<ActionProps> = (props) => {
const { children, ...actionProps } = props;
const actionModel = ActionModel.useNew(actionProps);
const { children, actionModel: actionModelFromProps, ...actionProps } = props;
const newActionModel = ActionModel.useNew(actionProps);
const actionModel = actionModelFromProps ?? newActionModel;

const propsContext: PropsContext = {
Button: actionButtonContext,
Expand Down
27 changes: 27 additions & 0 deletions packages/components/src/components/Action/ActionStateContext.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import type { FC, PropsWithChildren } from "react";
import React, { useEffect } from "react";
import { ActionModel } from "@/components/Action/models/ActionModel";
import Action from "@/components/Action/index";

interface Props extends PropsWithChildren {
isStarted?: boolean;
hasSucceeded?: boolean;
hasFailedWithError?: unknown;
}

export const ActionStateContext: FC<Props> = (props) => {
const { isStarted, hasFailedWithError, hasSucceeded, children } = props;
const action = ActionModel.useNew({});

useEffect(() => {
if (hasSucceeded) {
void action.state.onSucceeded();
} else if (hasFailedWithError) {
void action.state.onFailed(hasFailedWithError);
} else if (isStarted) {
void action.state.onAsyncStart();
}
}, [isStarted, hasFailedWithError, hasSucceeded]);

return <Action actionModel={action}>{children}</Action>;
};
2 changes: 2 additions & 0 deletions packages/components/src/components/Action/types.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
import type { PropsWithChildren } from "react";
import type { OverlayController } from "@/lib/controller";
import type { ActionModel } from "@/components/Action/models/ActionModel";

export type ActionFn = (...args: unknown[]) => unknown;

export interface ActionProps extends PropsWithChildren {
action?: ActionFn;
actionModel?: ActionModel;
closeOverlay?: boolean | OverlayController;
openOverlay?: boolean | OverlayController;
toggleOverlay?: boolean | OverlayController;
Expand Down
6 changes: 5 additions & 1 deletion packages/components/src/components/RadioGroup/RadioGroup.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,11 @@ export const RadioGroup = flowComponent("RadioGroup", (props) => {
return (
<Aria.RadioGroup {...rest} className={rootClassName} ref={ref}>
<TunnelProvider>
<PropsContextProvider props={propsContext} dependencies={[variant]}>
<PropsContextProvider
props={propsContext}
dependencies={[variant]}
mergeInParentContext
>
{children}

{variant === "segmented" && (
Expand Down
2 changes: 1 addition & 1 deletion packages/components/src/components/Section/Section.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ export const Section = flowComponent("Section", (props) => {
return (
<Activity isActive={isActive}>
<section {...rest} className={rootClassName} ref={ref}>
<PropsContextProvider props={propsContext}>
<PropsContextProvider props={propsContext} mergeInParentContext>
{children}
</PropsContextProvider>
</section>
Expand Down

This file was deleted.

Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import type {
FieldValues,
UseFormReturn,
} from "react-hook-form";
import { Controller as RHFController } from "react-hook-form";
import { Controller } from "react-hook-form";
import type { PropsContext } from "@/lib/propsContext";
import { dynamic, PropsContextProvider } from "@/lib/propsContext";
import { useFormContext } from "@/integrations/react-hook-form/components/context/formContext";
Expand All @@ -15,29 +15,34 @@ interface Props<T extends FieldValues>
extends Omit<ControllerProps<T>, "render">,
PropsWithChildren {}

export function Controller<T extends FieldValues>(props: Props<T>) {
export function Field<T extends FieldValues>(props: Props<T>) {
const { children, control, ...rest } = props;

const controlFromContext = useFormContext<T>().form?.control;

return (
<RHFController
<Controller
{...rest}
control={control ?? controlFromContext}
render={(renderProps) => {
const { field, fieldState } = renderProps;
const {
field,
fieldState: { error, invalid },
} = renderProps;

const formControlProps = {
...field,
isRequired: !!rest.rules?.required,
isInvalid: fieldState.invalid,
isInvalid: invalid,
validationBehavior: "aria" as const,
children: dynamic((p) => (
<>
{p.children}
<FieldError>{fieldState.error?.message}</FieldError>
<FieldError>{error?.message}</FieldError>
</>
)),
ref: undefined,
refProp: field.ref,
};

const propsContext: PropsContext = {
Expand Down Expand Up @@ -66,6 +71,6 @@ export function Controller<T extends FieldValues>(props: Props<T>) {
);
}

export const typedController = <T extends FieldValues>(
export const typedField = <T extends FieldValues>(
ignoredForm: UseFormReturn<T>,
): typeof Controller<T> => Controller;
): typeof Field<T> => Field;
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import { Field } from "@/integrations/react-hook-form/components/Field/Field";
export * from "./Field";
export default Field;
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,7 @@ import { useForm } from "react-hook-form";
import { action } from "@storybook/addon-actions";
import { TextField } from "@/components/TextField";
import { Label } from "@/components/Label";
import {
Controller,
Form,
typedController,
} from "@/integrations/react-hook-form";
import { Field, Form, typedField } from "@/integrations/react-hook-form";
import { Button } from "@/components/Button";
import { Section } from "@/components/Section";
import { ActionGroup } from "@/components/ActionGroup";
Expand All @@ -20,12 +16,13 @@ import { CheckboxGroup } from "@/components/CheckboxGroup";
import { Checkbox } from "@/components/Checkbox";
import Select, { Option } from "@/components/Select";
import { Slider } from "@/components/Slider";
import { sleep } from "@/lib/promises/sleep";

const submitAction = action("submit");

const meta: Meta<typeof Controller> = {
title: "Integrations/React Hook Form/Controller",
component: Controller,
const meta: Meta<typeof Field> = {
title: "Integrations/React Hook Form/Field",
component: Field,
render: () => {
interface Values {
firstName: string;
Expand All @@ -37,7 +34,8 @@ const meta: Meta<typeof Controller> = {
storage: number;
}

const onSubmit = (values: Values): void => {
const handleOnSubmit = async (values: Values) => {
await sleep(1500);
submitAction(values);
};

Expand All @@ -50,12 +48,12 @@ const meta: Meta<typeof Controller> = {
},
});

const TController = typedController(form);
const Field = typedField(form);

return (
<Form onSubmit={form.handleSubmit(onSubmit)} form={form}>
<Form form={form} onSubmit={handleOnSubmit}>
<Section>
<TController
<Field
name="firstName"
rules={{
required: "Please enter your name",
Expand All @@ -65,9 +63,9 @@ const meta: Meta<typeof Controller> = {
<Label>First name</Label>
<FieldDescription>The first part of your name</FieldDescription>
</TextField>
</TController>
</Field>

<TController
<Field
name="lastName"
rules={{
required: "Please select your last name",
Expand All @@ -79,9 +77,9 @@ const meta: Meta<typeof Controller> = {
<Option value="Williams">Williams</Option>
<Option value="Peters">Peters</Option>
</Select>
</TController>
</Field>

<TController
<Field
name="age"
rules={{
required: "Please enter your age",
Expand All @@ -91,9 +89,9 @@ const meta: Meta<typeof Controller> = {
<NumberField>
<Label>Age</Label>
</NumberField>
</TController>
</Field>

<TController
<Field
name="gender"
rules={{ required: "Please choose your gender" }}
>
Expand All @@ -103,15 +101,15 @@ const meta: Meta<typeof Controller> = {
<Radio value="female">Female</Radio>
<Radio value="diverse">Diverse</Radio>
</RadioGroup>
</TController>
</Field>

<TController name="testing">
<Field name="testing">
<Switch>
<Label>Activate testing</Label>
</Switch>
</TController>
</Field>

<TController
<Field
name="interests"
rules={{
validate: {
Expand All @@ -132,9 +130,9 @@ const meta: Meta<typeof Controller> = {
<Checkbox value="bar">Bar</Checkbox>
<Checkbox value="baz">Baz</Checkbox>
</CheckboxGroup>
</TController>
</Field>

<TController name="storage">
<Field name="storage">
<Slider
formatOptions={{
style: "unit",
Expand All @@ -145,7 +143,7 @@ const meta: Meta<typeof Controller> = {
>
<Label>Storage</Label>
</Slider>
</TController>
</Field>

<ActionGroup>
<Button type="submit">Submit</Button>
Expand All @@ -157,6 +155,6 @@ const meta: Meta<typeof Controller> = {
};
export default meta;

type Story = StoryObj<typeof Controller>;
type Story = StoryObj<typeof Field>;

export const Default: Story = {};
Original file line number Diff line number Diff line change
@@ -1,20 +1,74 @@
import type { ComponentProps, PropsWithChildren } from "react";
import type {
ComponentProps,
FormEventHandler,
PropsWithChildren,
} from "react";
import { useEffect } from "react";
import React from "react";
import type { FieldValues, UseFormReturn } from "react-hook-form";
import { useFormState } from "react-hook-form";
import { FormContextProvider } from "@/integrations/react-hook-form/components/context/formContext";
import { ActionStateContext } from "@/components/Action/ActionStateContext";

export type FormOnSubmitHandler<F extends FieldValues> = Parameters<
UseFormReturn<F>["handleSubmit"]
>[0];

interface Props<F extends FieldValues>
extends ComponentProps<"form">,
extends Omit<ComponentProps<"form">, "onSubmit">,
PropsWithChildren {
form?: UseFormReturn<F>;
form: UseFormReturn<F>;
onSubmit: FormOnSubmitHandler<F>;
}

export function Form<F extends FieldValues>(props: Props<F>) {
const { form, children, ...formProps } = props;
const { form, children, onSubmit, ...formProps } = props;

const { isValid, isSubmitted, isSubmitting, isSubmitSuccessful, errors } =
useFormState(form);

const unwatchedFormState = form.control._formState;

useEffect(() => {
if (isSubmitted && isValid) {
form.reset(undefined, {
keepIsSubmitted: false,
keepIsSubmitSuccessful: false,
keepDefaultValues: true,
keepValues: true,
keepDirtyValues: true,
keepIsValid: true,
keepDirty: true,
keepErrors: true,
keepTouched: true,
keepIsValidating: true,
keepSubmitCount: true,
});
}
}, [isSubmitted, isValid]);

const handleOnSubmit: FormEventHandler = (e) => {
if (unwatchedFormState.isSubmitting || unwatchedFormState.isValidating) {
e.preventDefault();
} else {
form.handleSubmit(onSubmit)(e);
}
};

const submitError = isSubmitted ? errors : undefined;
const submitSucceeded = isSubmitted && isSubmitSuccessful;

return (
<FormContextProvider value={{ form }}>
<form {...formProps}>{children}</form>
<form {...formProps} onSubmit={handleOnSubmit}>
<ActionStateContext
isStarted={isSubmitting}
hasFailedWithError={submitError}
hasSucceeded={submitSucceeded}
>
{children}
</ActionStateContext>
</form>
</FormContextProvider>
);
}
Original file line number Diff line number Diff line change
@@ -1,2 +1,2 @@
export * from "./components/Controller";
export * from "./components/Field";
export * from "./components/Form";

0 comments on commit d4b0dbd

Please sign in to comment.