Git Product home page Git Product logo

strictly-typed's Introduction

Performant, flexible and extensible forms with easy to use validation.

npm downloads npm npm

Goal

React Hook Form strictly typed custom hooks.

Install

$ npm install @hookform/strictly-typed

Quickstart

import { useTypedController } from '@hookform/strictly-typed';
import { useForm } from 'react-hook-form';
import { TextField, Checkbox } from '@material-ui/core';

type FormValues = {
  flat: string;
  nested: {
    object: { test: string };
    array: { test: boolean }[];
  };
};

export default function App() {
  const { control, handleSubmit } = useForm<FormValues>();
  const TypedController = useTypedController<FormValues>({ control });

  const onSubmit = handleSubmit((data) => console.log(data));

  return (
    <form onSubmit={onSubmit}>
      <TypedController
        name="flat"
        defaultValue=""
        render={(props) => <TextField {...props} />}
      />

      <TypedController
        as="textarea"
        name={['nested', 'object', 'test']}
        defaultValue=""
        rules={{ required: true }}
      />

      <TypedController
        name={['nested', 'array', 0, 'test']}
        defaultValue={false}
        render={(props) => <Checkbox {...props} />}
      />

      {/* โŒ: Type '"notExists"' is not assignable to type 'DeepPath<FormValues, "notExists">'. */}
      <TypedController as="input" name="notExists" defaultValue="" />

      {/* โŒ: Type 'number' is not assignable to type 'string | undefined'. */}
      <TypedController
        as="input"
        name={['nested', 'object', 0, 'notExists']}
        defaultValue=""
      />

      {/* โŒ: Type 'true' is not assignable to type 'string | undefined'. */}
      <TypedController as="input" name="flat" defaultValue={true} />

      <input type="submit" />
    </form>
  );
}

Edit React Hook Form - useTypedController

Name Reference

Field Path Field Name
foo foo
['foo', 'bar'] foo.bar
['foo', 0] foo[0]
['foo', '0'] foo.0
['foo', 1] foo[1]
['foo', 0, 'bar'] foo[0].bar
['foo'] foo
['foo', 'bar'] foo.bar
['foo', 'bar', 0] foo.bar[0]

API

  • useTypedController
Name Type Required
control Object
  • TypedController
Name Type Required
name string | [string, ...(string | number)[]] โœ“
as 'input' | 'select' | 'textarea'
render Function
defaultValue DeepPathValue
rules Object
onFocus () => void

Backers

Thanks goes to all our backers! [Become a backer].

Organizations

Thanks goes to these wonderful organizations! [Contribute].

Contributors

Thanks goes to these wonderful people! [Become a contributor].

strictly-typed's People

Contributors

bluebill1049 avatar keiya01 avatar kotarella1110 avatar

Stargazers

 avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar

Watchers

 avatar  avatar  avatar  avatar

strictly-typed's Issues

Unable to get input value of wrapped component in <TypedController /> on Jest

Unable to get input value of wrapped component in <TypedController /> on Jest.

Both codes work correctly in the browser. (Chrome 85.0.4183.102)

use <TypedController /> sample test code

Only the last test will not pass.

import React from 'react';
import { render, fireEvent, act } from '@testing-library/react';
import { SubmitHandler, useForm } from 'react-hook-form';
import { useTypedController } from '@hookform/strictly-typed';

const FORM_ID = 'test-textarea-id';
const SUBMIT_ID = 'test-submit-id';

type CustomeTextareaProps = {
  testId: string;
  name: string;
} & React.TextareaHTMLAttributes<HTMLTextAreaElement>;
const CustomeTextarea: React.FC<CustomeTextareaProps> = (props: CustomeTextareaProps) => {
  const { testId, ...textAreaProps } = props;

  return (
    <div>
      <textarea data-testid={testId} {...textAreaProps} />
    </div>
  );
};

type SampleFormValue = {
  sample: string;
};
type SampleFormProps = {
  onSubmit: (value: SampleFormValue) => void;
};
const SampleForm: React.FC<SampleFormProps> = (props: SampleFormProps) => {
  const { handleSubmit, control } = useForm<SampleFormValue>({
    criteriaMode: 'all',
  });
  const TypedController = useTypedController<SampleFormValue>({ control });
  const onSubmit: SubmitHandler<SampleFormValue> = (data) => {
    props.onSubmit(data);
  };

  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      <TypedController
        name={'sample'}
        render={(formProps) => <CustomeTextarea testId={FORM_ID} name={'sample'} {...formProps} />}
      />
      <input type="submit" data-testid={SUBMIT_ID} />
    </form>
  );
};

describe('SampleForm', () => {
  test('Input value should be equal to the form value', async () => {
    const mockOnSubmit = jest.fn();
    await act(async () => {
      const renderResult = render(<SampleForm onSubmit={mockOnSubmit} />);
      const input = renderResult.getByTestId(FORM_ID) as HTMLTextAreaElement;
      fireEvent.change(input, { target: { value: 'test-value' } });
      expect(input.value).toBe('test-value'); // No Error
    });
  });

  test('Submitted data should be equal to the input data', async () => {
    const mockOnSubmit = jest.fn();
    await act(async () => {
      const renderResult = render(<SampleForm onSubmit={mockOnSubmit} />);
      const input = renderResult.getByTestId(FORM_ID) as HTMLTextAreaElement;
      fireEvent.change(input, { target: { value: 'test-value' } });
      expect(input.value).toBe('test-value');
      fireEvent.submit(renderResult.getByTestId(SUBMIT_ID));
    });
    expect(mockOnSubmit).toBeCalled(); // No Error
    expect(mockOnSubmit).toBeCalledWith({}); // No Error
    expect(mockOnSubmit).toBeCalledWith({ sample: 'test-value' }); // -> Error
  });
});

Error log

SampleForm
    โˆš Input value should be equal to the form value (122 ms)
    ร— Submitted data should be equal to the input data (89 ms)

  โ— SampleForm โ€บ Submitted data should be equal to the input data

    expect(jest.fn()).toBeCalledWith(...expected)

    - Expected
    + Received

    - Object {
    -   "sample": "test-value",
    - }
    + Object {},

    Number of calls: 1

      71 |     expect(mockOnSubmit).toBeCalled(); // No Error
      72 |     expect(mockOnSubmit).toBeCalledWith({}); // No Error
    > 73 |     expect(mockOnSubmit).toBeCalledWith({ sample: 'test-value' }); // -> Error
         |                          ^
      74 |   });
      75 | });
      76 | 

without <TypedController /> sample test code

All tests pass.

import React from 'react';
import { render, fireEvent, act } from '@testing-library/react';
import { SubmitHandler, useForm } from 'react-hook-form';
import { useTypedController } from '@hookform/strictly-typed';

const FORM_ID = 'test-textarea-id';
const SUBMIT_ID = 'test-submit-id';

type CustomeTextareaProps = {
  testId: string;
  name: string;
} & React.TextareaHTMLAttributes<HTMLTextAreaElement>;

const CustomeTextarea = React.forwardRef<HTMLTextAreaElement, CustomeTextareaProps>(
  (props: CustomeTextareaProps, ref) => {
    const { testId, ...textAreaProps } = props;
    return (
      <div>
        <textarea data-testid={testId} ref={ref} {...textAreaProps} />
      </div>
    );
  }
);

type SampleFormValue = {
  sample: string;
};
type SampleFormProps = {
  onSubmit: (value: SampleFormValue) => void;
};
const SampleForm: React.FC<SampleFormProps> = (props: SampleFormProps) => {
  const { handleSubmit, register } = useForm<SampleFormValue>({
    criteriaMode: 'all',
  });
  const onSubmit: SubmitHandler<SampleFormValue> = (data) => {
    props.onSubmit(data);
  };

  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      <CustomeTextarea testId={FORM_ID} name={'sample'} ref={register()} />
      <input type="submit" data-testid={SUBMIT_ID} />
    </form>
  );
};

describe('SampleForm', () => {
  test('Input value should be equal to the form value', async () => {
    const mockOnSubmit = jest.fn();
    await act(async () => {
      const renderResult = render(<SampleForm onSubmit={mockOnSubmit} />);
      const input = renderResult.getByTestId(FORM_ID) as HTMLTextAreaElement;
      fireEvent.change(input, { target: { value: 'test-value' } });
      expect(input.value).toBe('test-value'); // -> No Error
    });
  });

  test('Submitted data should be equal to the input data', async () => {
    const mockOnSubmit = jest.fn();
    await act(async () => {
      const renderResult = render(<SampleForm onSubmit={mockOnSubmit} />);
      const input = renderResult.getByTestId(FORM_ID) as HTMLTextAreaElement;
      fireEvent.change(input, { target: { value: 'test-value' } });
      expect(input.value).toBe('test-value'); // -> No Error
      fireEvent.submit(renderResult.getByTestId(SUBMIT_ID));
    });
    expect(mockOnSubmit).toBeCalled(); // -> No Error
    expect(mockOnSubmit).toBeCalledWith({ sample: 'test-value' }); // -> No Error
  });
});

dependencies (package.json)

{
  ...
  "dependencies": {
    "@aspida/axios": "^0.9.4",
    "@emotion/core": "^10.0.28",
    "@hookform/resolvers": "^0.1.0",
    "@hookform/strictly-typed": "^0.0.4",
    "axios": "^0.19.2",
    "css-element-queries": "^1.2.3",
    "dayjs": "^1.8.36",
    "next": "^9.4.4",
    "next-images": "^1.4.0",
    "nprogress": "^0.2.0",
    "qrcode.react": "^1.0.0",
    "query-string": "^6.13.1",
    "react": "^16.13.1",
    "react-dom": "^16.13.1",
    "react-hook-form": "^6.0.2",
    "react-loading-skeleton": "^2.1.1",
    "react-modal": "^3.11.2",
    "react-toastify": "^6.0.8",
    "react-tooltip": "^4.2.8",
    "swr": "^0.3.0",
    "ts-node": "^8.10.2",
    "vanilla-lazyload": "^17.1.0"
  },
  "devDependencies": {
    "@babel/core": "^7.10.4",
    "@next/bundle-analyzer": "^9.4.4",
    "@stoplight/prism-cli": "^3.3.7",
    "@storybook/addon-a11y": "^5.3.19",
    "@storybook/addon-actions": "^5.3.19",
    "@storybook/addon-backgrounds": "^5.3.19",
    "@storybook/addon-docs": "^5.3.19",
    "@storybook/addon-knobs": "^5.3.19",
    "@storybook/react": "^5.3.19",
    "@testing-library/jest-dom": "^5.11.0",
    "@testing-library/react": "^10.4.4",
    "@testing-library/react-hooks": "^3.4.1",
    "@types/jest": "^26.0.4",
    "@types/node": "^14.0.14",
    "@types/nprogress": "^0.2.0",
    "@types/qrcode.react": "^1.0.1",
    "@types/react": "^16.9.41",
    "@types/react-modal": "^3.10.6",
    "@types/react-tooltip": "^4.2.4",
    "@types/testing-library__jest-dom": "^5.9.1",
    "@typescript-eslint/eslint-plugin": "^3.5.0",
    "@typescript-eslint/parser": "^3.5.0",
    "babel-loader": "^8.1.0",
    "babel-preset-react-app": "^9.1.2",
    "cross-env": "^7.0.2",
    "eslint": "^7.4.0",
    "eslint-config-prettier": "^6.11.0",
    "eslint-plugin-prettier": "^3.1.4",
    "eslint-plugin-react": "^7.20.3",
    "husky": "^4.2.5",
    "jest": "^26.1.0",
    "openapi2aspida": "^0.9.0",
    "prettier": "^2.0.5",
    "react-is": "^16.13.1",
    "react-test-renderer": "16.9.0",
    "stylelint": "^13.6.1",
    "stylelint-config-prettier": "^8.0.2",
    "stylelint-config-standard": "^20.0.0",
    "ts-jest": "^26.1.1",
    "typescript": "3.8.3"
  }
}

Using TypedController for nested input and useFormContext

Hello,
I'm trying to use a TypedController inside my nested input components combined with useFormContext.
I want to maintain the type-safety of name and defaultValue props, using the example from the README of this repository as a basis.

Here's my working reproduction of the issue: stackblitz/Amiryy/react-hook-form-strictly-typed-poc

So I've composed a NestedInput component:

import { DeepPath, DeepPathValue } from '@hookform/strictly-typed/dist/types';

// NOTE the usage of DeepPath for the types of name and defaultValue props.
// I need these props to match the types of TypedController's name & defaultValue props

interface NestedInputProps<T, Path extends DeepPath<T, Path>> {
  name: Path;
  defaultValue: DeepPathValue<T, Path>
}

function NestedInput<T, Path extends DeepPath<T, Path> = keyof T>(
  props: NestedInputProps<T, Path>
) {
  const { control, handleSubmit } = useFormContext<T>();
  const TypedController = useTypedController<T>({ control });

  return (
    <TypedController
      name={props.name}
      defaultValue={props.defaultValue}
      as={"input"}
    />
  )
}

To make TS accept my code, I had to use imported types such as DeepPath and DeepPathValue for my name and defaultValue props.

As you can see in the snippet above - the second argument of my generic NestedInputProps is Path, which for my understanding represents the path to a nested (or not) property in my form's object type. This type is then used for name prop to match TypedController's name prop's type.

Once I've tried to use my NestedInput inside a form I couldn't avoid having to pass the Path argument:

    {/* 
      MY NESTED EXAMPLES: I am forced to pass a duplication of name prop to make both name 
      and defaultValue props type-safe
    */}
        <NestedInput<FormValues, "flat"> 
          name={"flat"} 
          defaultValue={"flat input"} 
        />
        {/* โŒ: not passing additional the argument - TS should complain here on defaultValue being a number but it doesn't */}
        <NestedInput<FormValues /*, "flat" */> 
          name={"flat"} 
          defaultValue={1} 
        />

        <NestedInput<FormValues, ["letters", "a"]> 
          name={["letters", "a"]} 
          defaultValue={"A"} 
        />
        {/* โŒ: not passing additional the argument - keyof FormValues is expected by default */}
        <NestedInput<FormValues /*, ["letters", "a"] */> 
          name={["letters", "a"]} 
          defaultValue={"A"} 
        />

        <NestedInput<FormValues, ["letters", "b"]> 
          name={["letters", "b"]} 
          defaultValue={2} 
        />
        {/* โŒ: Type 'string' is not assignable to type 'number'. */}
        <NestedInput<FormValues, ["letters", "b"]> 
          name={["letters", "b"]} 
          defaultValue={"B"} 
        />
        {/* โŒ: Type '"c"' is not assignable to type '"a" | "b"'. */}
        <NestedInput<FormValues, ["letters", "c"]> 
          name={["letters", "c"]} 
          defaultValue={1} 
        />

It seems like when using <TypedController /> directly the types of name and defaultValue are inferred by their values, so when passing the name prop a property's path, makes defaultValue expect a corresponding type of the property's value.
(from the code example):

{/* โŒ: Type '"notExists"' is not assignable to type 'DeepPath<FormValues, "notExists">'. */}
<TypedController as="input" name="notExists" defaultValue="" />

{/* โŒ: Type 'number' is not assignable to type 'string | undefined'. */}
<TypedController
    as="input"
    name={['nested', 'object', 0, 'notExists']}
    defaultValue=""
/>

{/* โŒ: Type 'true' is not assignable to type 'string | undefined'. */}
<TypedController as="input" name="flat" defaultValue={true} />

And so my bottom-line question is... how can I maintain this usability when passing down name and defaultValue props to a nested <TypedController />?

Thanks!

useTypedController remounts component repeatedly

Hi,

useTypedController only recreates TypedController component when control is changed:

But actually useForm returns different control object on every render, and TypedController is always recreated.
https://github.com/react-hook-form/react-hook-form/blob/e757653b39645bf9f547525d27f88224d8049539/src/useForm.ts#L1162

This causes the input component to be unmounted and remounted repeatedly, and can impact on both performance and experience.

We are encountering the worst case of it, where the input component triggers rerender of the parent on its initial render using useEffect:

Because TypedController is always "different" component, React remounts the component endlessly with the following warning:

Warning: Maximum update depth exceeded. This can happen when a component calls setState inside useEffect, but useEffect either doesn't have a dependency array, or one of the dependencies changes on every render.
    in TextInput (at App.tsx:23)
    in Controller
    in Unknown (at App.tsx:20)
    in div (at App.tsx:18)
    in App (at src/index.tsx:7)

Thanks.

useTypedController.d.ts does not account for passing of refs

Under useTypedController.d.ts:

  render?: ((props: {
        onChange: (...event: any[]) => void;
        onBlur: () => void;
        value: import("./types").DeepPathValue<UFieldValues, TFieldName>;
    }) 

Only onChange, onBlur, and value are allowed to be passed as props

The issue comes due to the issue: react-hook-form/react-hook-form#3411

I am attempting to render MUI TextInput in TypedController.

           <TypedController
             name="price_ceil"
             defaultValue=""
             render={(props) => (
               <TextInput
                 label="When the price has risen to"
                 variant="outlined"
                 type="number"
                 inputProps={{
                   'data-testid': 'Min-Price-Input',
                 }}
                 error={!!errors.price_ceil}
                 helperText={errors.price_ceil?.message}
                 {...props}
               />
             )}
           />

However, I am receiving the warning that Function components cannot be given refs. Attempts to access this ref will fail. Did you mean to use React.forwardRef()?.
The fix is to pass a ref to InputRef when rendering TextInput.

However, due to the typing in useTypedController, I get the typing error Property 'ref' does not exist on type '{ onChange: (...event: any[]) => void; onBlur: () => void; value: string | number | undefined; }'.

Will there be any issues with passing ref as well in the render for TypedController?

Typescript error if FormValues does not contain any nested attributes.

Reproduction

https://codesandbox.io/s/react-hook-form-usetypedcontroller-forked-hdzbr?file=/src/App.tsx

This was created by forking the offical example and updating all versions to the latest

Issue

Line 32 has error

 Types of parameters 'name' and 'name' are incompatible.
            Type 'string' is not assignable to type '"uncontrolled" | "controlled" | "error"'.

If you uncomment lines 23-26 the example will work as expected.

Expected behaviour

Nested attributes should not be a requirement when using @hookform/strictly-typed and typescript

Improve type safety

Is your feature request related to a problem? Please describe.
I'd like my forms to be more strongly typed to avoid any mismatch between inputs in the DOM, validations defined by the form and the submit handler.

Describe the solution you'd like
I'd like a solution that's careful about how it uses and transforms my types, so I could trust it more as a type safe library, not just a form library with better autocomplete. Here's a concept:

interface Inputs {
  firstName: string;
  lastName: string;
  middleNames: Array<{middleName: string;}>;
  age: number;
}

const { control, handleSubmit } = useForm<Inputs>({validate: {presence: ['firstName', 'lastName'] as const}});

// UseFormOptions['validate']['presence'] would be 'firstName' | 'lastName'
// InvertInputs<Inputs, 'firstName' | 'lastName'> would be 'middleNames' | 'age'
// MarkPartial<{a: string}, 'a'> would transform it to {a: string | undefined}
function onSubmit(data: MarkPartial<Inputs, InvertInputs<Inputs, UseFormOptions['validate']['presence']>>) {
  type Test1 = typeof data.firstName;
  // string
  type Test2 = typeof data.age;
  // number | undefined
  // note the automatic undefined
}
return <form onSubmit={handleSubmit(onSubmit)}>
  <Controller
    as='input'
    name='firstName' // firstName validated against `keyof Inputs`, this works today
    control={control}
  />
</form>

This example shows a safer transformation of types. Right now it's possible to define any types you want and not be sure of anything in onSubmit. The above snippet might not be enough, sometimes it's tricky to have values at the type level and runtime level and keep them aligned, but I hope something like this is possible.

I also propose improving the Field Array functionality by delegating the control variable downstream. If it was possible for useFieldArray to return it's own control object, we could propagate this type safety into nested and repeated fields, which would be excellent.

Describe alternatives you've considered
I considered staying with redux-form, but it's even less type friendly.

Additional context
I'm liking the library a lot so far, but I want to migrate to something that uses TypeScript to keep my forms in check.

TypedController render props object doesn't have a name prop

I'm currently defining the generic as any because I get an error on this discussion and I'm not sure how to handle it.

type FormData = any;

//...
  const TypedController = useTypedController<FormData>({ control });

//...

  <TypedController
    name="documentType"
    render={props => {
      debugger;   // props does not contains a `name`
      return (
        <TextField {...props />
      )
    }

The props object in the render prop contains props like onBlur and many others, but name is not one of them.

Did I miss anything?

useTypedController render prop signature does not contain name and ref props

Here is the root of the problem:

strictly-typed/src/types.ts

Lines 145 to 149 in 382a93a

render?: (props: {
onChange: (...event: any[]) => void;
onBlur: () => void;
value: DeepPathValue<TFieldValues, TFieldName>;
}) => React.ReactElement;

Expected behavior:
render function argument contains name ref declaration

Current behavior:
render function argument does not contain ref and name declaration

Probably solution should look like this:

    render?: (props: {
      onChange: (...event: any[]) => void;
      onBlur: () => void;
      value: DeepPathValue<TFieldValues, TFieldName>;
      name: TFieldName;
      ref: React.MutableRefObject<TAs>;
    }) => React.ReactElement;

Recommend Projects

  • React photo React

    A declarative, efficient, and flexible JavaScript library for building user interfaces.

  • Vue.js photo Vue.js

    ๐Ÿ–– Vue.js is a progressive, incrementally-adoptable JavaScript framework for building UI on the web.

  • Typescript photo Typescript

    TypeScript is a superset of JavaScript that compiles to clean JavaScript output.

  • TensorFlow photo TensorFlow

    An Open Source Machine Learning Framework for Everyone

  • Django photo Django

    The Web framework for perfectionists with deadlines.

  • D3 photo D3

    Bring data to life with SVG, Canvas and HTML. ๐Ÿ“Š๐Ÿ“ˆ๐ŸŽ‰

Recommend Topics

  • javascript

    JavaScript (JS) is a lightweight interpreted programming language with first-class functions.

  • web

    Some thing interesting about web. New door for the world.

  • server

    A server is a program made to process requests and deliver data to clients.

  • Machine learning

    Machine learning is a way of modeling and interpreting data that allows a piece of software to respond intelligently.

  • Game

    Some thing interesting about game, make everyone happy.

Recommend Org

  • Facebook photo Facebook

    We are working to build community through open source technology. NB: members must have two-factor auth.

  • Microsoft photo Microsoft

    Open source projects and samples from Microsoft.

  • Google photo Google

    Google โค๏ธ Open Source for everyone.

  • D3 photo D3

    Data-Driven Documents codes.