elmish-spreadsheet
elmish-spreadsheet copied to clipboard
Implement your own Excel 365 in 100 lines of F#!
Excel 365 in 100ish lines of F#
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.
Getting started
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
Following the tutorial
Step #1: Keeping cell state
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.fsand go torenderEditor. You can see that we already have an event handler forOnInputwhich shows a message usingwindow.alert. Change the code to trigger theUpdateValueevent using thetriggerfunction (the first parameter ofUpdateValueshould be the positionposand the second should be the value of the input, i.e.e.target?value). -
Open
main.fsand go to theupdatefunction. This needs to handle theUpdateValueevent and calculate a new state. When we get an eventUpdateValue(pos, value), we need to create a newstate.Cellsmap and add a mapping fromposttovalue(usingMap.add) -
Finally, open
main.fsand go to therenderCellfunction. Right now, this passes"!"and"?"to therenderEditorandrenderViewfunctions. Find a value for the current cell usingMap.tryFind pos state.Cells. You can handleNoneusingOption.defaultValue(just make the default empty) and pass it torenderEditorandrenderView.
Now you should be able to edit the value in cell A1!
Step #2: Selecting a cell
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
Stateinmain.fsand add a new fieldActiveof typePosition option(this keeps the selected cell position orNoneif no cell is selected). In theinitialfunction, returnNone. -
To change the selected cell, we need a new type of event. Find the
Eventtype (inmain.fs) and add a new caseStartEditthat carries aPositionvalue. -
Modify
updatefunction inmain.fsto handle the newStartEditevent. When the event happens withposas the new position to be selected, return a new state withActiveset toSome(pos). -
Go to
renderCelland 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
renderViewfunction inmain.fs. This creates a<td>element with the cell. In the attributes of the element, add a handler forOnClickthat triggers (using thetriggerfunction) theStartEdit(pos)event. (The code is similar toOnInputthat we already have inrenderEditor.)
Now you can click on cells and change their values!
Step #3: Rendering the grid
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.Colsas 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
viewcode 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
cellfunction 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).
Step #4: Evaluating equations
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, runparseon 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
parseand thenevaluate. Since parsing can fail, you'll needOption.mapor pattern matching to do this. Also, theevalautefunction takes all cells too, so you need to call it usingevaluate state.Cells parsed. -
Finally, the code for
evaluateinevaluator.fsjust returns 0, 1 or 2. Modify this to actually evaluate the expression! ForNumber, just return the number; forBinary, recursively evaluatelandrand 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!
Step #5: Add proper error handling
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
evaluatefunction inevaluator.fsso that it returnsoption<int>rather than justint. You will need to returnSomein theNumbercase and propagate theNonevalues correctly - the easiest way to do this is usingOption.bindandOption.map, but you can also use pattern matching usingmatch. -
Once you modify
evaluate, you also need to modifyrenderCellinmain.fsso that it calls it correctly. If you passNonetorenderView, it will display#ERRin 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
evaluatefunction 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.