This is a minimal tutorial showing how to use F#, Fable (F# to JavaScript compiler) and Elmish (a library implementing the model-view-update architecture) to build a simple clone of Excel running as a client-side web application.
To run the tutorial, you will need to install Fable pre-requirements and get an F# editor that works well with Fable. The recommended setup is to use VS Code with Ionide.
- dotnet SDK 2.0 or higher
- node.js with npm
- Ionide instructions
We start with four cells and cell A1 is always selected, but you cannot actually edit the text in the cell! This is because we do not correctly store the state of the textbox that the user edits.
-
Open
main.fs
and go torenderEditor
. You can see that we already have an event handler forOnInput
which shows a message usingwindow.alert
. Change the code to trigger theUpdateValue
event using thetrigger
function (the first parameter ofUpdateValue
should be the positionpos
and the second should be the value of the input, i.e.e.target?value
). -
Open
main.fs
and go to theupdate
function. This needs to handle theUpdateValue
event and calculate a new state. When we get an eventUpdateValue(pos, value)
, we need to create a newstate.Cells
map and add a mapping frompost
tovalue
(usingMap.add
) -
Finally, open
main.fs
and go to therenderCell
function. Right now, this passes"!"
and"?"
to therenderEditor
andrenderView
functions. Find a value for the current cell usingMap.tryFind pos state.Cells
. You can handleNone
usingOption.defaultValue
(just make the default empty) and pass it torenderEditor
andrenderView
.
Now you should be able to edit the value in cell A1!
Now, we need to allow the user to select another cell. To do this, we will need to track the active cell in our state and add events for selecting another cell.
-
Find the definition of
State
inmain.fs
and add a new fieldActive
of typePosition option
(this keeps the selected cell position orNone
if no cell is selected). In theinitial
function, returnNone
. -
To change the selected cell, we need a new type of event. Find the
Event
type (inmain.fs
) and add a new caseStartEdit
that carries aPosition
value. -
Modify
update
function inmain.fs
to handle the newStartEdit
event. When the event happens withpos
as the new position to be selected, return a new state withActive
set toSome(pos)
. -
Go to
renderCell
and modify the conditionpos = ('A', 1)
. Rather than checking that we are rendering cell A1, we need to check whether we are rendering the cell specified instate.Active
(note that this is an option type so you need to compare againstSome(pos)
or useOption.contains
). -
Finally, we need code that will trigger our new event. Find the
renderView
function inmain.fs
. This creates a<td>
element with the cell. In the attributes of the element, add a handler forOnClick
that triggers (using thetrigger
function) theStartEdit(pos)
event. (The code is similar toOnInput
that we already have inrenderEditor
.)
Now you can click on cells and change their values!
So far, we only had 4 cells. Those are created by hand in the view
function.
We want to change the code so that it generates cells dynamically, using the
cell and row keys in state.Cols
and state.Rows
.
To do this, you can either use list comprehensions with [ .. yield .. ]
syntax
or you can use List.map
function. The following steps describe how to use
List.map
, which is easier if you are new to F# (but if you know F# already,
feel free to use list comprehensions!)
-
You can generate headers using
List.map
. Usestate.Cols
as the input. In the body of the map function, you can create a header usingheader (string h)
. You also need to append the empty cell usingempty::headers
. -
The original
view
code defines two rows usinglet cells1 = ...
andlet cells2 = ...
. First, modify the body to generate cell for each column instate.Cols
(just like for the headers). Next, modify the code to be a function that takes a row numben
. -
Finally, use your new
cell
function to generate a row for every single row of the spreadsheet specified instate.Rows
. If you are usingList.map
, the argument will need to generate a row usingtr [] (cells r)
.
Finally, we need to add an evaluator for spreadsheet formulas! The parse
function is already implemented (in evaluator.fs
) so you need to add the
evaluator and put everything together.
-
In
renderCell
, when we are handling a cell that is not selected, we want to parse and evaluate the code and pass the result torenderView
. First, runparse
on the cell value (when it isSome value
) and then format the result usingstring
. This way, you should see what the result of parsing looks like. -
Next, modify the code to call
parse
and thenevaluate
. Since parsing can fail, you'll needOption.map
or pattern matching to do this. Also, theevalaute
function takes all cells too, so you need to call it usingevaluate state.Cells parsed
. -
Finally, the code for
evaluate
inevaluator.fs
just returns 0, 1 or 2. Modify this to actually evaluate the expression! ForNumber
, just return the number; forBinary
, recursively evaluatel
andr
and then apply the binary operator; forReference
, you will need to find the value incells
, parse it and evaluate that recursively. Do not worry about correct error handling. We'll fix that next!
The evaluator can fail when you reference a cell without a value (it will crash) or when you reference a cell within itself (it will run into an infinite loop), so let's fix that!
-
Modify the
evaluate
function inevaluator.fs
so that it returnsoption<int>
rather than justint
. You will need to returnSome
in theNumber
case and propagate theNone
values correctly - the easiest way to do this is usingOption.bind
andOption.map
, but you can also use pattern matching usingmatch
. -
Once you modify
evaluate
, you also need to modifyrenderCell
inmain.fs
so that it calls it correctly. If you passNone
torenderView
, it will display#ERR
in red just like Excel. -
Handling recursive references is harder. We currently just get into an infinite loop and get a stack overflow. To handle this, you need to modify the
evaluate
function so that it has an additional parameter of typeSet<Position>
that keeps a set with all cells that we are evaluating. Then, when handlingReference
, you need to make sure that the referenced cell is not in this set.