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
}
How shall we define this trigFcn
type? We could make it a variant type:
type trigFcn =
| Sin
| Cos
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
}
Finally, you need a variant data type for the kind of graph the user wants:
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—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)
}
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
The code then becomes:
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:
| 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.
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—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"
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)
}
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)
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:
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:
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:
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.
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:
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:
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 aninputResult
and repackage it in aformulaResult
- 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})
}
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)
}
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)
}
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)
}
}
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)