GoncharukOrg / react-input

111 stars 9 forks source link

Initial number formatting not working together with react-hook-form Controller component #26

Open stass-1 opened 5 months ago

stass-1 commented 5 months ago

Initial value loaded by setValue method of react-hook-form is not formatting. But keyboard input formatting works correctly. Programmatically triggering onChange event and onKeyDown event wont help.

        const form = useForm({
        mode: 'all',
    })

    const inputRef = useNumberFormat({
        locales: 'en',
        maximumFractionDigits: 2
    })

        useEffect(() => {
                form.setValue('field-name', externalValue, { shouldValidate: true, shouldDirty: true, shouldTouch: true })
        }, [externalValue])

    return <Controller
        render={
            ({field}) =>
                <TextField {...field} inputRef={inputRef} />
        }
        name="field-name"
        control={form.control}
        rules={{required: true}}
    />
GoncharukBro commented 5 months ago

Please send the full usage code, what initial value are you passing?

patrickfreitasdev commented 2 months ago

Having the similar issue

The initial value comes from the API, let's say the phone value 4799999999 which will mask to (47)999-9999

// Function to modify the input mask based on the value
export const phoneModify = (input: string) => {
  if ((input.length >= 6 && input[5] === '9') || input.length === 11) {
    return {
      mask: '(__) _____-____',
    }
  }
  return {
    mask: '(__) ____-_____',
  }
}
// Mask logic
  const phone01InputRef = useMask({
    mask: '(__) ____-_____',
    replacement: { _: /\d/ },
    modify: phoneModify,
    onMask: (event) => {
      setValue('phone_01', event.detail.value)
    },
  })
   <Input
          className="mt-2"
          {...register('phone_01')}
          ref={phone01InputRef}
        />

It uses React hook for and react query, when the component is mounted the data is fetched from API to refill the fields and allow users to edit the field.

I tried using setValue() and reset() inside useEffect, from hook form with no luck, the console.log using watch shows the value is set to field but it is still blank, the defaultValue() field is filled but the mask is not triggered.

patrickfreitasdev commented 2 months ago

I created a page / component that gives a better idea:

'use client'
import { zodResolver } from '@hookform/resolvers/zod'
import { MaskEventHandler, useMask } from '@react-input/mask'
import { useQuery } from '@tanstack/react-query'
import { useEffect } from 'react'
import { useForm } from 'react-hook-form'
import { z } from 'zod'

interface ApiResponse {
  zip_code: string
  city: string
}

async function simulateApiCall() {
  await new Promise((resolve) => setTimeout(resolve, 3000))

  return {
    zip_code: '12345678',
    city: 'São Paulo',
  } as ApiResponse
}

const formSchema = z.object({
  zip_code: z
    .string({
      message: 'CEP inválido',
    })
    .min(8, {
      message: 'CEP inválido',
    })
    .max(9, {
      message: 'CEP inválido',
    }),
  city: z.string().min(3, {
    message: 'Cidade é obrigatória',
  }),
})

export type CompanyFormType = z.infer<typeof formSchema>

export default function Page() {
  // Handle the search zip code logic Todo: Implement the loading status
  const handleMaskZip: MaskEventHandler = async (event) => {
    console.log('event', event)
  }

  // Mask logic
  const ZipInputRef = useMask({
    mask: '_____-___',
    replacement: { _: /\d/ },
    onMask: handleMaskZip,
  })

  const { data } = useQuery({
    queryKey: ['loja', 1],
    queryFn: () => {
      return simulateApiCall()
    },
  })

  async function handleUserUpdateSubmit(data: CompanyFormType) {
    console.log('data', data)
  }

  const {
    register,
    handleSubmit,
    reset,
    formState: { errors },
  } = useForm<CompanyFormType>({
    resolver: zodResolver(formSchema),
  })

  useEffect(() => {
    if (data) {
      reset({
        zip_code: data.zip_code,
        city: data.city,
      })
    }
  }, [data, reset])

  return (
    <div>
      <form
        onSubmit={handleSubmit(handleUserUpdateSubmit)}
        className="w-full p-4"
      >
        <input className="border" {...register('zip_code')} ref={ZipInputRef} />
        {errors.zip_code && <span>{errors.zip_code.message}</span>}

        <input className="border" {...register('city')} />
        {errors.city && <span>{errors.city.message}</span>}
      </form>
    </div>
  )
}

You can see only one field is populated ( the one without mask )

01
GoncharukBro commented 2 months ago

@patrickfreitasdev you are overriding the ref property, thus preventing the element from being registered for the form, since react-hook-form itself expects the ref callback returned by form.register to be called.

You need to store references to the element for @react-input/mask and react-hook-form like this:

import { useState, useEffect } from 'react';
import { useMask } from '@react-input/mask';
import { useForm } from 'react-hook-form';

interface ApiResponse {
  zip_code: string;
  city: string;
}

function useQuery() {
  const [data, setData] = useState<ApiResponse | null>(null);

  useEffect(() => {
    setTimeout(() => {
      setData({ zip_code: '12345-678', city: 'São Paulo' });
    }, 3000);
  }, []);

  return data;
}

export default function Page() {
  const data = useQuery();

  const maskRef = useMask({ mask: '_____-___', replacement: { _: /\d/ } });
  const form = useForm();

  const zipCodeRegistration = form.register('zip_code');
  const cityRegistration = form.register('city');

  useEffect(() => {
    if (data) {
      form.setValue('zip_code', data.zip_code);
      form.setValue('city', data.city);
    }
  }, [data]);

  const ref = (element: HTMLInputElement | null) => {
    zipCodeRegistration.ref(element);
    maskRef.current = element;
  };

  return (
    <form onSubmit={form.handleSubmit(console.log)}>
      <input {...zipCodeRegistration} ref={ref} />
      <input {...cityRegistration} />{' '}
    </form>
  );
}

Note that a masked input will be initialized with exactly the value you give it, so you must ensure a masked value for initialization or format it manually (if the initialized value is not masked) using the format utility (https://github.com/GoncharukBro/react-input/tree/main/packages/mask#format):

const value = format(data.zip_code, { mask, replacement });
form.setValue('zip_code', value);

More.

Starting with @react-input/mask@2.0.0, we removed the input-mask event and the onMask method, focusing only on using native React events and methods such as onChange, since the input-mask event cannot be explicitly coordinated with React events and methods, making such usage and event firing order non-obvious.

To use the useful data from the detail property of the input-mask (onMask) event object, you can also use the utilities described in the «Utils» section.

The release of @react-input/mask@2.0.0 is expected next week.

patrickfreitasdev commented 2 months ago

Hi @GoncharukBro

Thanks, that makes sense, the code you provided is working, though when I receive the data using React query, the field is filled but the mask isn't triggered so it will fill as 12345678 rather than 12345-678, it triggers fine on type,

You can see it with

'use client'
import { zodResolver } from '@hookform/resolvers/zod'
import { MaskEventHandler, useMask } from '@react-input/mask'
import { useQuery } from '@tanstack/react-query'
import { useEffect } from 'react'
import { useForm } from 'react-hook-form'
import { z } from 'zod'

interface ApiResponse {
  zip_code: string
  city: string
}

async function simulateApiCall() {
  await new Promise((resolve) => setTimeout(resolve, 3000))

  return {
    zip_code: '12345678',
    city: 'São Paulo',
  } as ApiResponse
}

const formSchema = z.object({
  zip_code: z
    .string({
      message: 'CEP inválido',
    })
    .min(8, {
      message: 'CEP inválido',
    })
    .max(9, {
      message: 'CEP inválido',
    }),
  city: z.string().min(3, {
    message: 'Cidade é obrigatória',
  }),
})

export type CompanyFormType = z.infer<typeof formSchema>

export default function Page() {
  const { data } = useQuery({
    queryKey: ['loja', 1],
    queryFn: () => {
      return simulateApiCall()
    },
  })

  async function handleUserUpdateSubmit(data: CompanyFormType) {
    console.log('data', data)
  }

  const {
    register,
    handleSubmit,
    reset,
    formState: { errors },
  } = useForm<CompanyFormType>({
    resolver: zodResolver(formSchema),
  })

  const maskRef = useMask({ mask: '_____-___', replacement: { _: /\d/ } })
  const zipCodeRegistration = register('zip_code')

  const ref = (element: HTMLInputElement | null) => {
    zipCodeRegistration.ref(element)
    maskRef.current = element
  }

  useEffect(() => {
    if (data) {
      reset({
        zip_code: data.zip_code,
        city: data.city,
      })
    }
  }, [data, reset])

  return (
    <div>
      <form
        onSubmit={handleSubmit(handleUserUpdateSubmit)}
        className="w-full p-4"
      >
        <input {...zipCodeRegistration} ref={ref} />
        {errors.zip_code && <span>{errors.zip_code.message}</span>}

        <input className="border" {...register('city')} />
        {errors.city && <span>{errors.city.message}</span>}
      </form>
    </div>
  )
}

Console error returns

Error: An invalid character was found in the initialized property value value or defaultValue (index: 5). Check the correctness of the initialized value in the specified property.

I will keep digging here, thank you in advance.

patrickfreitasdev commented 2 months ago

Ok, console error is gone with

setValue('zip_code', data.zip_code)

I was using reset() so all good on that, sill the mask doesn't trigger.

GoncharukBro commented 2 months ago

@patrickfreitasdev

As the documentation and my previous comment suggest, @react-input/mask does not change the value passed in the value or defaultValue properties of the input element, it only changes the input process, for obvious reasons. So set the initialized value to something that can match the masked value at any point in the input. If you make a mistake, you will see a warning in the console about it.

If you have a non-masked value, use the format utility to initialize the value (see «Utils»).

import { useMask, format } from '@react-input/mask';

// ...

  useEffect(() => {
    if (data) {
      const zipCodeMaskedValue = format(data.zip_code, { mask, replacement });

      form.setValue('zip_code', zipCodeMaskedValue);
      form.setValue('city', data.city);
    }
  }, [data]);
patrickfreitasdev commented 2 months ago

@GoncharukBro Awesome, thanks, all clear now.