fable-compiler / fable-react

Fable bindings and helpers for React and React Native
MIT License
273 stars 67 forks source link

How to use react-number-format #198

Closed halcwb closed 3 years ago

halcwb commented 4 years ago

I want to use this library: https://github.com/s-yadav/react-number-format. Is this something that will be implemented in the near future?

alfonsogarciacaro commented 4 years ago

Hi @halcwb! We don't plan to add support for specific React libraries here, though some community members have created binding for React/JS libraries that you can find here: https://fable.io/community/

It's not difficult to use external React components directly from JS. You can find the documentation here: https://github.com/fable-compiler/fable-react/blob/master/docs/using-third-party-react-components.md

halcwb commented 4 years ago

@alfonsogarciacaro Thanks for the quick reply, I will definitely have a look at that.

halcwb commented 3 years ago

This is still quite a bit more complicated than I assumed. The problem that I run into is that the fable-react binding doesn't contain the complete typescript definition for react. So, for the number format library I need to use the React.InputHTMLAttributes<HTMLInputElement> interface as this is the required props type:

I am not sure how to approach this problem. Do I need to go manually into the rabbit hole of adding all the needed supertypes? Because InputHTMLAttributes<T> extends HTMLAttributes<T>, etc...

P.S. the goal is to contribute a React/Js library.

Shmew commented 3 years ago

You can download Fable.Browser.Dom and open Browser.Types to gain access to elements like HTMLInputElement.

halcwb commented 3 years ago

@Shmew Thanks, but that namespace was already opened. I also added the Fable.React library. The problem is that the Fable.React library doesn't seem to match the react typescript definitions at all. When I manually start adding all the required react interfaces from the react type definition file, I need to keep adding interfaces as each interface extends other interfaces, etc...

I assumed that the Fable.React library would contain all react types.

halcwb commented 3 years ago

@alfonsogarciacaro , @Shmew , @Zaid-Ajaj .

It took me some time, and I have things almost working using the following setup (full code in this repos):

  1. I have a javascript file to import the react-number-format lib like this:
import React from 'react';
import NumberFormat from 'react-number-format';

function merge(o1, o2) {
    const o = { ...o1, ...o2 };
    console.log("merged", o);
    // although inputRef is a member of the object, it cannot be removed
    // because of inputRef is not defined error
    // return { inputRef, ..o };
    return o;  
}

export { merge, NumberFormat as default };

I also have a short utility function (merge) to enable merging two objects. This is necessary because the following code has to be implemented:

function NumberFormatCustom(props) {
  const { inputRef, onChange, ...other } = props;

  return (
    <NumberFormat
      {...other}
      getInputRef={inputRef}
      onValueChange={(values) => {
        onChange({
          target: {
            name: props.name,
            value: values.value,
          },
        });
      }}
      thousandSeparator
      isNumericString
      prefix="$"
    />
  );
}

So the NumberFormat is an react type (not element) that has to be set as an inputComponent of the inputProps.

      <TextField
        label="react-number-format"
        value={values.numberformat}
        onChange={handleChange}
        name="numberformat"
        id="formatted-numberformat-input"
        InputProps={{
          inputComponent: NumberFormatCustom,
        }}
      />
  1. I can then use the number format in the following code:
module NumberInput2 =
    open System
    open System.ComponentModel

    open Elmish
    open Thoth.Elmish
    open Feliz
    open Feliz.UseElmish
    open Feliz.MaterialUI
    open Fable.MaterialUI.Icons
    open Fable.Core
    open Fable.Core.JsInterop
    open Browser

    let private merge (o1: obj) (o2: obj) : obj = import "merge" "./../number-format.js"

    [<Erase>]
    type NumberFormat =

        static member inline numberformat =
            fun props ->

                let props =
                    {|
                        onValueChange =
                            fun values ->
                                printfn "getting value: %A" values?value
                                {| target = {| value = values?value |} |}
                        decimalSeparator = ","
                        thousandSeparator = "."
                    |}
                    |> merge props

                ofImport "default" "./../number-format.js" props []

            |> ReactElementType.ofFunction

    type State =
        {
            Error: bool
            UserInput: string
        }

    type Msg =
        | ChangeValue of string
        | EndOfInput

    type Props =
        {| min: float option
           max: float option
           step: float
           label: string
           adorn: string
           dispatch: string -> unit |}

    let private init () =
        {
            //Debouncer = Debouncer.create ()
            Error = false
            UserInput = ""
            //Number = None
        },
        Cmd.none

    let private update (props: Props) msg state =
        let tryParse (s : string) =
            match Double.TryParse(s) with
            | true, f   -> f |> Some
            | false, _  -> None

        let isErr (s: string) =
            if s.Trim() = "" then
                true
            else
                match s |> tryParse, props.min, props.max with
                | None, _, _ -> false
                | Some _, None, None -> true
                | Some v, Some min, None -> v >= min
                | Some v, None, Some max -> v <= max
                | Some v, Some min, Some max -> v >= min && v <= max
            |> not

        match msg with
        | ChangeValue s ->

            { state with
                Error = s |> isErr
                UserInput = s
            },
            Cmd.none

        | EndOfInput ->
            let state =
                { state with
                    Error = state.UserInput |> isErr
                }

            state,
            Cmd.ofSub (fun _ -> state.UserInput |> props.dispatch)

    let useStyles err =
        Styles.makeStyles (fun styles theme ->
            {|
                field =
                    styles.create [
                        style.minWidth (theme.spacing 14)
                        style.marginTop (theme.spacing 1)
                    ]
                input =
                    styles.create
                        [
                            if not err then
                                style.color (theme.palette.primary.main)
                            else
                                style.color (theme.palette.error.main)
                        ]
                label =
                    styles.create [
                        style.fontSize (theme.typography.fontSize - 15.)
                        style.paddingRight (theme.spacing 2)
                    ]
            |})

    let defaultProps: Props =
        {|
            min = None
            max = None
            step = 1.
            label = ""
            adorn = ""
            dispatch = (fun (s: string) -> ())
        |}

    let private comp =
        React.functionComponent
            ("numericinput",
             (fun (props: Props) ->
                 let state, dispatch =
                     React.useElmish (init, update props, [||])

                 let classes = (useStyles state.Error) ()

                 Mui.textField [
                     prop.className classes.field
                     textField.error state.Error
                     textField.label
                         (Mui.typography [
                             typography.variant.body2
                             typography.children [ props.label ]
                          ])

                     textField.value state.UserInput
                     textField.onChange (ChangeValue >> dispatch)

                     // dirty fix to disable number field typ in FF
                     let isFF =
                         navigator.userAgent.ToLower().Contains("firefox")
                     if not isFF then textField.type' "text"

                     textField.size.small
                     textField.InputProps [
                         // uses the react-number-format lib
                         input.inputComponent (NumberFormat.numberformat)

                         input.inputProps [
                             prop.step props.step
                             match props.min with
                             | Some m -> prop.min m
                             | None -> prop.min 0.
                             match props.max with
                             | Some m -> prop.max m
                             | None -> ()
                         ]

                         // sets the color of the input value
                         prop.className classes.input
                         // adds a unit (adornment) to a value
                         input.endAdornment
                             (Mui.inputAdornment [
                                 inputAdornment.position.end'
                                 inputAdornment.children
                                     [
                                         Mui.typography [
                                             typography.color.textSecondary
                                             typography.variant.body2
                                             typography.children [ props.adorn ]
                                         ]
                                     ]
                              ])
                     ]
                 ]))

    let render label adorn dispatch =
        comp
            ({| defaultProps with
                 label = label
                 adorn = adorn
                 dispatch = dispatch
             |})

    let renderWithProps props = comp (props)

It sort of works but for 2 problems:

  1. I cannot remove the inputRef prop using my javascript merge function. When I log the merged object, the property is there and react complains about it. But if I want to exclude the property using the spread syntax (commented in the code) then I get an error.

  2. When I type into the textfield, every time it loses focus. I suspect because for each render a new component is created and react doesn't recognize it as the same component.

Any suggestions would be very much appreciated.

Shmew commented 3 years ago

Hey @halcwb, I took a look at your repro and got it working:

open System
open Elmish
open Feliz
open Feliz.UseElmish
open Feliz.MaterialUI
open Fable.Core
open Fable.Core.JsInterop
open Browser

type NumberFormatValues =
    { formattedValue: string
      value: string
      floatValue: float }

[<Erase>]
type numberFormat =
    static member inline customInput (elem: ReactElement) = Interop.mkAttr "customInput" elem
    static member inline format (fmt: string) = Interop.mkAttr "format" fmt        
    static member inline onValueChange (handler: NumberFormatValues -> unit) = Interop.mkAttr "onValueChange" handler
    static member inline decimalSeparator (value: char) = Interop.mkAttr "decimalSeparator" value
    static member inline decimalSeparator (value: string) = Interop.mkAttr "decimalSeparator" value
    static member inline thousandSeparator (value: char) = Interop.mkAttr "thousandSeparator" value
    static member inline thousandSeparator (value: string) = Interop.mkAttr "thousandSeparator" value

type React with
    static member inline numberformat (props: IReactProperty list) = 
        Interop.reactElement (importDefault "react-number-format") (createObj !!props)

type State =
    { Error: bool
      UserInput: string }

type Msg =
    | ChangeValue of string
    | EndOfInput

type Props =
    {| min: float option
       max: float option
       step: float
       label: string
       adorn: string
       dispatch: string -> unit |}

let private init () =
    { Error = false
      UserInput = "" }
    , Cmd.none

let private update (props: Props) msg state =
    let tryParse (s : string) =
        match Double.TryParse(s) with
        | true, f   -> f |> Some
        | false, _  -> None

    let isErr (s: string) =
        if s.Trim() = "" then true
        else
            match s |> tryParse, props.min, props.max with
            | None, _, _ -> false
            | Some _, None, None -> true
            | Some v, Some min, None -> v >= min
            | Some v, None, Some max -> v <= max
            | Some v, Some min, Some max -> v >= min && v <= max
        |> not

    match msg with
    | ChangeValue s -> { state with Error = s |> isErr; UserInput = s }, Cmd.none
    | EndOfInput ->
        let state = { state with Error = state.UserInput |> isErr }

        state,
        Cmd.ofSub (fun _ -> state.UserInput |> props.dispatch)

let private useStyles err =
    Styles.makeStyles (fun styles theme ->
        {| field = styles.create [
               style.minWidth (theme.spacing 14)
               style.marginTop (theme.spacing 1)
           ]
           input = styles.create [
               if not err then style.color (theme.palette.primary.main)
               else style.color (theme.palette.error.main)
           ]
           label = styles.create [
               style.fontSize (theme.typography.fontSize - 15.)
               style.paddingRight (theme.spacing 2)
           ]
        |})

let private defaultProps: Props =
    {| min = None
       max = None
       step = 1.
       label = ""
       adorn = ""
       dispatch = (fun (s: string) -> ()) |}

let private comp = React.functionComponent("numericinput", fun (props: Props) ->
    let state, dispatch = React.useElmish (init, update props, [||])
    let classes = (useStyles state.Error) ()

    React.numberformat [
        prop.className classes.field
        textField.error state.Error
        textField.label (
            Mui.typography [
                typography.variant.body2
                typography.children [ props.label ]
            ]
        )

        textField.value state.UserInput
        textField.onChange (ChangeValue >> dispatch)

        let isFF =
            navigator.userAgent.ToLower().Contains("firefox")
        if not isFF then textField.type' "text"

        textField.size.small
        textField.InputProps [
            input.inputProps [
                prop.step props.step
                match props.min with
                | Some m -> prop.min m
                | None -> prop.min 0.
                match props.max with
                | Some m -> prop.max m
                | None -> ()
            ]

            prop.className classes.input
            input.endAdornment (
                Mui.inputAdornment [
                    inputAdornment.position.end'
                    inputAdornment.children [
                        Mui.typography [
                            typography.color.textSecondary
                            typography.variant.body2
                            typography.children [ props.adorn ]
                        ]
                    ]
                ]
            )
        ]
        numberFormat.customInput (import "TextField" "@material-ui/core")
        numberFormat.decimalSeparator ','
        numberFormat.thousandSeparator '.'
    ])

let render label adorn dispatch =
    comp
        {| defaultProps with
               label = label
               adorn = adorn
               dispatch = dispatch |}

let renderWithProps props = comp (props)
halcwb commented 3 years ago

@Shmew Thanks so much, works like a charm. Took me days but couldn't figure this out.

So, the whole trick is to pass a material-ui textfield as custom input?

Shmew commented 3 years ago

@Shmew Thanks so much, works like a charm. Took me days but couldn't figure this out.

You're welcome!

So, the whole trick is to pass a material-ui textfield as custom input?

Yeah the library passes the props down to the component type you provide. As far as implementing this type of behavior with Fable.React (I'm assuming that's something you want to do given the repo this issue is in), I'm not sure how you would make that work. I don't think you can extend a type to add an interface, but if you can that's a solution.