Getting Information from the DOM in ReScript (Part 2)
Published: 2020-08-26 21:08:57

In the preceding article, you learned enough to get started with `bs-webapi`. Now it’s time to use that library to extract data from the HTML input fields. There’s a lot of things going on here, so take a deep breath, and let’s dive in.

## Defining Types

Let’s define a type for a formula that the user will enter. We’ll use a *record* for this:

```reasonml
type formula = {
  factor: float,
  fcn: trigFcn,
  theta: float,
  offset: float
}
```

How shall we define this `trigFcn` type? We could make it a variant type:

```reasonml
type trigFcn = 
  | Sin
  | Cos
```

But instead, make `trigFcn` an actual function, so you can directly assign `sin` or `cos` to the field.

```reasonml
type trigFcn = (float) => float

type formula = {
  factor: float,
  fcn: trigFcn,
  theta: float,
  offset: float
}
```

Finally, you need a variant data type for the kind of graph the user wants:

```reasonml
type graphType =
  | Polar
  | Lissajous
```

## Finding the Draw Button

To get access to an element, you must call the `getElementById()` function, which takes as its arguments the element ID and a DOM document. This function returns an `option` type&mdash;it’s possible that the button doesn’t exist. **Important:** ReScript’s type checking doesn’t prevent you from having an error. It *does* require you to handle both the error and non-error cases. Here’s the code for the moment:

```reasonml
let optButton = Webapi.Dom.Document.getElementById("draw", Webapi.Dom.document)
switch (optButton) {
  | Some(button) => ()
  | None => Webapi.Dom.Window.alert("Cannot find button", Webapi.Dom.window)
}
```

The `()` is ReScript’s `unit` value, which is morally equivalent to `void` in other programming languages. You may have noticed that there’s a lot of repetition of `Webapi.Dom`. You can eliminate that repetition by putting some module aliases at the beginning of the file:

```reasonml
module DOM = Webapi.Dom  // use all upper case to distinguish from built-in Dom
module Doc = Webapi.Dom.Document
module Elem = Webapi.Dom.Element
module HtmlElem = Webapi.Dom.HtmlElement
module InputElem = Webapi.Dom.HtmlInputElement
module EvtTarget = Webapi.Dom.EventTarget
```

The code then becomes:

```reasonml
let optButton = Doc.getElementById("drawx", DOM.document)
switch (optButton) {
  | Some(button) => ()
  | None => DOM.Window.alert("Cannot find button", DOM.window)
}
```

Let’s set a click handler for the button (when it exists) to call a function called `draw()`. The `addClickEventListener` function requires an `EventTarget`, not a generalized `Element`, so you have to force ReScript to treat it as the correct type:

```reasonml
  | Some(button) => {
      EvtTarget.addClickEventListener(draw, Elem.asEventTarget(button))
    }
```

You’ll need the `draw()` function, which takes an event as its parameter. For now, put in a placeholder function. This function *must* precede its first use. 

```reasonml
let draw = (evt) => {
  Js.log(evt);
  Js.log("Button clicked")
}
```

Open up the web console in your browser, and you’ll see something like this when you click the button:

```
click { target: button#draw, buttons: 0, clientX: 505, clientY: 230, layerX: 505, layerY: 230 }
Button clicked
```

## Getting a Text Field’s Value

When the user clicks the Draw button, the program needs to go through the form fields and extract the data. Let’s start with the numeric values. Here’s the plan:

* If the ID of the field exists, then
    * If the text field is empty, use a default value
    * Convert to a `float`. If successful, return that value
    * Otherwise, return a “not numeric” error
* otherwise, return an “ID not found” error

You cannot use an `option` here&mdash;it either has a `Some(value)` or `None`. Instead, you must use `Belt.Result`, which can return an `Ok(value)` or `Error(otherValue)`.

You’ll also need to take the generic element from `getElementById()` and convince ReScript to treat it as an `HtmlInputElement`:

```reasonml
type numResult = Belt.result.t<float, string>
external unsafeAsHtmlInputElement: Elem.t => InputElem.t = "%identity"
```

In the preceding code, the first line establishes a type for our result: `Ok` values are `float`; `Error` values are `string`.  The second line is a magic voodoo spell that forces the conversion. You can pass *any* element to this function, and ReScript will blindly return it as an `HtmlInputElement`. If you pass something that really isn’t an `<input>` element, well, that’s on you! That’s why it has `unsafe` in its name.

Once these are established, you can write a function that takes an element ID and a default value and returns a `numResult`. Although ReScript would do the correct type inference, I’m now annotating the functions and explicitly giving the parameter and return types to make them clear to *me*, not to the computer.

```reasonml
let getNumericValue = (id: string, default: float): numResult => {
  let converter = (s: string, default: float): numResult => {
    if (s == "") {
      Belt.Result.Ok(default)
    } else {
      switch (float_of_string(s)) {
        | result => Belt.Result.Ok(result)
        | exception Failure(_) =>
            Belt.Result.Error(s ++ " is not numeric.")
      }
    }
  }
  getInputValue(id, default, converter)
}
```
Extra bonus: this code shows you one way to handle exceptions in ReScript. You can test this by adding the following code in the `draw()` function:

```reasonml
  let testNumeric = getNumericValue("factor1", 1.0)
  Js.log(testNumeric)
```

If you enter a valid number (like 5.3) and a non-numeric (like “blah”) in the first input field and then click the Draw button, you’ll see something like this in the web console:

```
Object { TAG: 0, _0: 5.3 }
Object { TAG: 1, _0: "blah is not numeric." }
```

## Eliminating Duplication

The next step is to get an entire formula: the factor, function, theta multiplier, and offset. Getting the function shares a lot of code with getting numeric input:

* If the ID of the field exists, then
    * If the text field is empty, use a default value
    * Convert to a `trigFcn`. If successful, return that value
    * Otherwise, return an “unknown function“ error
* otherwise, return an “ID not found“ error

Add another convenience type for the result:

```reasonml
type numResult = Belt.Result.t<float, string>
type fcnResult = Belt.Result.t<trigFcn, string>
```

Do you see a pattern here? This is a perfect place to use a *parametric data type*, which is similar to *generics* in other programming language. The following code creates a generalized `inputResult` type and a `getInputValue()` function that takes an element id, a default value, and a function that converts a string (the value in the input field) to the appropriate `Result` type:

```reasonml
let getInputValue = (id: string, default: 'a, f: (string, 'a)=> inputResult<'a>):
  inputResult<'a> => {
  switch (Doc.getElementById(id, DOM.document)) {
    | Some(element) => {
        let s = InputElem.value(unsafeAsHtmlInputElement(element))
        f(s, default)
      }
    | None => Belt.Result.Error("No element" ++ id)
  }
}
```

This lets you rewrite `getNumericValue()` as:

```reasonml
let getNumericValue = (id: string, default: float): numResult => {
  let converter = (s: string, default: float): numResult => {
    if (s == "") {
      Belt.Result.Ok(default)
    } else {
      switch (float_of_string(s)) {
        | result => Belt.Result.Ok(result)
        | exception Failure(_) =>
            Belt.Result.Error(s ++ " is not numeric.")
      }
    }
  }
  getInputValue(id, default, converter)
}
```

The function for getting the trigonometric function then becomes the following. There’s no need for a default value here, as both possibilities are accounted for in the drop-down menu. That’s why there is an underscore in `_default`, to tell ReScript that you know you won’t be using that variable.

```reasonml
let getFunctionValue = (id: string): fcnResult => {
  let converter = (s: string, _default: trigFcn): fcnResult => {
    if (s == "sin") {
      Belt.Result.Ok(sin)
    } else if (s == "cos") {
      Belt.Result.Ok(cos)
    } else {
      Belt.Result.Error("Unknown trig function " ++ s)
    }
  }
  getInputValue(id, sin, converter);
}
```

## Building a formula

You can now read all the fields to put together a `formula` variable. Since any of the elements of the formula can fail, you need a `Result` type for the formula:

```reasonml
type formulaResult = Belt.Result.t<formula, string>
```

One approach to putting together a formula would be a deeply nested series of `switch` statements to separate out the `Ok` and `Error` conditions for each part of a formula. Normally, instead of a nested `switch`, you would use `Belt.Result.map()` and `Belt.Result.flatMap()` to “pass along” the `Ok` conditions and stop when you hit an `Ok`.

Unfortunately, in this case each of the items in the formula is itself a `Result`, and neither `map()` nor `flatMap()` does what you want.  However, there’s no law that stops you from writing your own custom function:

```reasonml
let multiMap = (
  rX: Belt.Result.t<'a, 'b>, // this will be our formula
  rY: Belt.Result.t<'c, 'b>, // this is the result of getting an input field
  f: ('c, 'a) => 'a):        // function to insert input value ('c) into formula ('a)
    Belt.Result.t<'a, 'b> => {

  switch (rX, rY) {
    | (Belt.Result.Ok(x), Belt.Result.Ok(y)) =>
        Belt.Result.Ok(f(y, x))
    | (Belt.Result.Ok(_x), Belt.Result.Error(err)) =>
        (Belt.Result.Error(err): Belt.Result.t<'a, 'b>)
    | (err, _) => err
  }
}
```

Thus, given a `formulaResult` and an `inputResult`, the preceding code does this:

* If both the formula and input value are `Ok`, use a function to put the value into the formula.
* If the formula is `Ok` so far, but you have an input error, pass on the input error. The type annotation here is required to extract the error value from an `inputResult` and repackage it in a `formulaResult`
* If the formula so far has an error, pass it along

Here’s the code for putting together a formula from the fields that end with a given suffix (either "1" or "2"). It uses `->` to pass the result of one `multiMap()` call as the first parameter of the next one.

```reasonml
let getFormula = (suffix: string): Belt.Result.t<formula, string> => {

  // start with a "neutral" formula that is Ok
  let possibleFormula = Belt.Result.Ok(
     {factor: 1.0, fcn: sin, theta: 1.0, offset: 0.0}
  )
  
  multiMap(possibleFormula, getNumericValue("factor" ++ suffix, 1.0),
    (factor, form) => {...form, factor: factor}) ->
  multiMap(getFunctionValue("fcn" ++ suffix),
    (fcn, formula) => {...formula, fcn: fcn}) ->
  multiMap(getNumericValue("theta" ++ suffix, 1.0),
    (theta, formula) => {...formula, theta: theta}) ->
  multiMap(getNumericValue("offset" ++ suffix, 0.0),
    (offset, formula) => {...formula, offset: offset})
}
```

Each of the anonymous functions uses the `...` spread operator to create a new formula with the given value.

You can test this code by changing `draw()` as follows. The code doesn’t need any information from the event, so `_evt` starts with an underscore. Try entering non-numbers in several fields and see what happens.

```reasonml
let draw = (_evt) => {
  let testFormula = getFormula("1")
  Js.log(testFormula)
}
```

Once you’re satisfied with this, add code to get the selected radio button. The following code takes two parameters. The first is an array of two-tuples giving the radio button values and the corresponding variant data type value. The second parameter is a default value. This code is very *laissez-faire*; if there is any error, it defaults to a polar graph.

```reasonml
let getRadioValue = (radioButtons: array<(string, 'a)>, default: 'a) => {
  let rec helper = (index: int) => {
    if (index == Belt.Array.length(radioButtons)) {
      default
    } else {
      switch (Doc.getElementById(fst(radioButtons[index]), DOM.document)) {
        | Some(element) => {
            let input = unsafeAsHtmlInputElement(element)
            if (InputElem.checked(input)) {
              snd(radioButtons[index])
            } else {
              helper(index + 1)
            }
          }
        | None => helper(index + 1)
      }
    }
  }
  helper(0)
}
```

Now you can write the final version of `draw()` to collect all the form data.

```reasonml
let draw = (_evt) => {
  let formula1 = getFormula("1")
  let formula2 = getFormula("2")
  let plotAs = getRadioValue([("polar", Polar), ("lissajous", Lissajous)], Polar)
  switch (formula1, formula2) {
    | (Belt.Result.Ok(f1), Belt.Result.Ok(f2)) => {
        Js.log2("formula 1:", f1)
        Js.log2("formula 2:", f2)
        Js.log2("plot as: ", plotAs)
      }
    | (Belt.Result.Error(e1), _) => DOM.Window.alert(e1, DOM.window)
    | (_, Belt.Result.Error(e2)) => DOM.Window.alert(e2, DOM.window)
  }
}
```

There’s a lot of material here. You’ll probably want to look at the code carefully to see what’s going on. (And if you find a way to make it better, let me know.)

In the next and final part of this series, you’ll take the formula information and create the graph.