DEV Community

J David Eisenberg
J David Eisenberg

Posted on

Getting Information from the DOM in ReScript (Part 2)

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:

type formula = {
  factor: float,
  fcn: trigFcn,
  theta: float,
  offset: float
}
Enter fullscreen mode Exit fullscreen mode

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

type trigFcn = 
  | Sin
  | Cos
Enter fullscreen mode Exit fullscreen mode

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

type trigFcn = (float) => float

type formula = {
  factor: float,
  fcn: trigFcn,
  theta: float,
  offset: float
}
Enter fullscreen mode Exit fullscreen mode

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

type graphType =
  | Polar
  | Lissajous
Enter fullscreen mode Exit fullscreen mode

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—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:

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)
}
Enter fullscreen mode Exit fullscreen mode

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:

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
Enter fullscreen mode Exit fullscreen mode

The code then becomes:

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

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:

  | Some(button) => {
      EvtTarget.addClickEventListener(draw, Elem.asEventTarget(button))
    }
Enter fullscreen mode Exit fullscreen mode

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.

let draw = (evt) => {
  Js.log(evt);
  Js.log("Button clicked")
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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—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:

type numResult = Belt.result.t<float, string>
external unsafeAsHtmlInputElement: Elem.t => InputElem.t = "%identity"
Enter fullscreen mode Exit fullscreen mode

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.

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)
}
Enter fullscreen mode Exit fullscreen mode

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:

  let testNumeric = getNumericValue("factor1", 1.0)
  Js.log(testNumeric)
Enter fullscreen mode Exit fullscreen mode

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." }
Enter fullscreen mode Exit fullscreen mode

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:

type numResult = Belt.Result.t<float, string>
type fcnResult = Belt.Result.t<trigFcn, string>
Enter fullscreen mode Exit fullscreen mode

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:

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)
  }
}
Enter fullscreen mode Exit fullscreen mode

This lets you rewrite getNumericValue() as:

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)
}
Enter fullscreen mode Exit fullscreen mode

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.

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);
}
Enter fullscreen mode Exit fullscreen mode

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:

type formulaResult = Belt.Result.t<formula, string>
Enter fullscreen mode Exit fullscreen mode

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:

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
  }
}
Enter fullscreen mode Exit fullscreen mode

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.

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})
}
Enter fullscreen mode Exit fullscreen mode

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.

let draw = (_evt) => {
  let testFormula = getFormula("1")
  Js.log(testFormula)
}
Enter fullscreen mode Exit fullscreen mode

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.

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)
}
Enter fullscreen mode Exit fullscreen mode

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

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)
  }
}
Enter fullscreen mode Exit fullscreen mode

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.

Top comments (0)