A calculator in a Shiny app
Install the app from GitHub using:
# install.packages("remotes")
remotes::install_github("grddavies/shinycalculator")
Run the app using:
shinycalculator::run_app()
I developed this app as a resource for teaching how to test R code using testthat
and shiny apps using shinytest
.
Have a look here to see how the app is launched in a headless browser session using shinytest (and PhantomJS).
A session is then simulated using the methods associated with the app
object, and the expected behaviour tested using testthat
.
I'd like to point out some things (as well as the tests) which I think are cool about it:
-
Modular code structure - the calculator is a shiny module itself, which consists of a screen and 20 instances of a
calc_button
module, which interact with the screen. If you want to update how all the buttons work, you only update the two functions inmod_calc_button.R
. -
Quasiquotation - to give each button different functionality, one of the arguments passed to
mod_calc_button_server()
is a callback function (kind of - its a call rather than a function). The call is evaluated in the parent scope of the module server every time a button is pressed. When I first tried to do this, callback would only be evaluated once - it was being consumed by the first button press. I fixed that with this commit wherecallback
is quoted when the module is first called, and then that call can be evaluated without being consumed. -
Safe evaluation of user input - rather than reinvent the wheel by constructing my own mathematical expression parser, the calculator uses the R interpreter to evaluate user input on the server. It would be a pretty major security issue if the user could submit arbitrary instructions to run on our server so I had a look at how to get around this. I found this rstudio community question which uses rlang to inspect the user input, step through the calls in the expression, and only evaluate the call if the function belongs to a list of whitelisted functions. That way the calculator can evaluate
(1 + 1) * 10
but notsystem("sudo rm -rf /")
if the whitelist is set up correctly!