Skip to content

Implement support for clientside callbacks in Dash for R #130

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 20 commits into from
Oct 1, 2019
Merged
Show file tree
Hide file tree
Changes from 18 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions .circleci/config.yml
Original file line number Diff line number Diff line change
Expand Up @@ -22,11 +22,12 @@ jobs:
echo "JOB PARALLELISM: ${CIRCLE_NODE_TOTAL}"
echo "CIRCLE_REPOSITORY_URL: ${CIRCLE_REPOSITORY_URL}"
echo $CIRCLE_JOB > circlejob.txt
git rev-parse HEAD | tr -d '\n' > commit.txt

- run:
name: 🚧 install R dependencies
command: |
sudo Rscript -e 'install.packages("remotes"); remotes::install_github("plotly/dashR", dependencies=TRUE, upgrade=TRUE); install.packages(".", type="source", repos=NULL)'
sudo Rscript -e 'commit_hash <- readChar("commit.txt", file.info("commit.txt")$size); message("Preparing to install plotly/dashR ", commit_hash, " ..."); install.packages("remotes"); remotes::install_github("plotly/dashR", upgrade=TRUE, ref=commit_hash, force=TRUE)'

- run:
name: ⚙️ Integration tests
Expand All @@ -36,7 +37,7 @@ jobs:
git clone --depth 1 https://github.com/plotly/dash.git
cd dash && pip install -e .[testing] --quiet && cd ..
export PATH=$PATH:/home/circleci/.local/bin/
pytest --cli-log-level DEBUG tests/integration/
pytest tests/integration/

- run:
name: 🔎 Unit tests
Expand Down
1 change: 1 addition & 0 deletions NAMESPACE
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ S3method(print,dash_component)
export(Dash)
export(dashNoUpdate)
export(createCallbackId)
export(clientsideFunction)
export(input)
export(output)
export(state)
Expand Down
25 changes: 20 additions & 5 deletions R/dash.R
Original file line number Diff line number Diff line change
Expand Up @@ -76,13 +76,20 @@
#' \describe{
#' \item{output}{a named list including a component `id` and `property`}
#' \item{params}{an unnamed list of [input] and [state] statements, each with defined `id` and `property`}
#' \item{func}{any valid R function which generates [output] provided [input] and/or [state] arguments}
#' \item{func}{any valid R function which generates [output] provided [input] and/or [state] arguments, or a call to [clientsideFunction] including `namespace` and `function_name` arguments for a locally served JavaScript function}
#' }
#' The `output` argument defines which layout component property should
#' receive the results (via the [output] object). The events that
#' trigger the callback are then described by the [input] (and/or [state])
#' object(s) (which should reference layout components), which become
#' argument values for the callback handler defined in `func`.
#' argument values for R callback handlers defined in `func`.
#'
#' `func` may either be an anonymous R function, or a call to
#' `clientsideFunction()`, which describes a locally served JavaScript
#' function instead. The latter defines a "clientside callback", which
#' updates components without passing data to and from the Dash backend.
#' The latter may offer improved performance relative to callbacks written
#' in R.
#' }
#' \item{`run_server(host = Sys.getenv('DASH_HOST', "127.0.0.1"),
#' port = Sys.getenv('DASH_PORT', 8050), block = TRUE, showcase = FALSE, ...)`}{
Expand Down Expand Up @@ -221,7 +228,8 @@ Dash <- R6::R6Class(
list(
inputs=callback_signature$inputs,
output=createCallbackId(callback_signature$output),
state=callback_signature$state
state=callback_signature$state,
clientside_function=callback_signature$clientside_function
)
}, private$callback_map)

Expand Down Expand Up @@ -534,14 +542,21 @@ Dash <- R6::R6Class(

inputs <- params[vapply(params, function(x) 'input' %in% attr(x, "class"), FUN.VALUE=logical(1))]
state <- params[vapply(params, function(x) 'state' %in% attr(x, "class"), FUN.VALUE=logical(1))]

if (is.function(func)) {
clientside_function <- NULL
} else {
clientside_function <- func
func <- NULL
}

# register the callback_map
private$callback_map <- insertIntoCallbackMap(private$callback_map,
inputs,
output,
state,
func)

func,
clientside_function)
},

# ------------------------------------------------------------------------
Expand Down
15 changes: 11 additions & 4 deletions R/utils.R
Original file line number Diff line number Diff line change
Expand Up @@ -352,11 +352,12 @@ clean_dependencies <- function(deps) {
return(deps_with_file)
}

insertIntoCallbackMap <- function(map, inputs, output, state, func) {
insertIntoCallbackMap <- function(map, inputs, output, state, func, clientside_function) {
map[[createCallbackId(output)]] <- list(inputs=inputs,
output=output,
state=state,
func=func
func=func,
clientside_function=clientside_function
)
if (length(map) >= 2) {
ids <- lapply(names(map), function(x) dash:::getIdProps(x)$ids)
Expand All @@ -365,7 +366,7 @@ insertIntoCallbackMap <- function(map, inputs, output, state, func) {
outputs_as_list <- mapply(paste, ids, props, sep=".", SIMPLIFY = FALSE)

if (length(Reduce(intersect, outputs_as_list))) {
stop(sprintf("One or more outputs are duplicated across callbacks. Please ensure that all ID and property combinations are unique."), call. = FALSE)
stop(sprintf("One or more outputs are duplicated across callbacks. Please ensure that all ID and property combinations are unique."), call. = FALSE)
}
}
return(map)
Expand Down Expand Up @@ -413,7 +414,9 @@ assert_valid_callbacks <- function(output, params, func) {

# Assert that user_function is a valid function
if(!(is.function(func))) {
stop(sprintf("The callback method's 'func' parameter requires a function as its argument. Please verify that 'func' is a valid, executable R function."), call. = FALSE)
if (!(all(names(func) == c("namespace", "function_name")))) {
stop(sprintf("The callback method's 'func' parameter requires an R function or clientside_function call as its argument. Please verify that 'func' is either a valid R function or clientside_function."), call. = FALSE)
}
}

# Check if inputs are a nested list
Expand Down Expand Up @@ -916,3 +919,7 @@ getIdProps <- function(output) {
props <- vapply(unlist(idprops, recursive=FALSE), '[', character(1), 2)
return(list(ids=ids, props=props))
}

clientsideFunction <- function(namespace, function_name) {
return(list(namespace=namespace, function_name=function_name))
}
6 changes: 6 additions & 0 deletions tests/integration/clientside/assets/clientside.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
if(!window.dash_clientside) {window.dash_clientside = {};}
window.dash_clientside.clientside = {
display: function (value) {
return 'Client says "' + value + '"';
}
}
73 changes: 73 additions & 0 deletions tests/integration/clientside/test_clientside.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
from selenium.webdriver.support.select import Select
import time, os

app = """
library(dash)
library(dashCoreComponents)
library(dashHtmlComponents)

app <- Dash$new()

app$layout(htmlDiv(list(
dccInput(id='input'),
htmlDiv(id='output-clientside'),
htmlDiv(id='output-serverside')
)
)
)

app$callback(
output(id = "output-serverside", property = "children"),
params = list(
input(id = "input", property = "value")
),
function(value) {
sprintf("Server says %s", value)
}
)

app$callback(
output('output-clientside', 'children'),
params=list(input('input', 'value')),
clientsideFunction(
namespace = 'clientside',
function_name = 'display'
)
)

app$run_server()
"""


def test_rscc001_clientside(dashr):
os.chdir(os.path.dirname(__file__))
dashr.start_server(app)
dashr.wait_for_text_to_equal(
'#output-clientside',
'Client says "undefined"'
)
dashr.wait_for_text_to_equal(
"#output-serverside",
"Server says NULL"
)
input1 = dashr.find_element("#input")
dashr.clear_input(input1)
input1.send_keys("Clientside")
dashr.wait_for_text_to_equal(
'#output-clientside',
'Client says "Clientside"'
)
dashr.wait_for_text_to_equal(
"#output-serverside",
"Server says Clientside"
)
dashr.clear_input(input1)
input1.send_keys("Callbacks")
dashr.wait_for_text_to_equal(
'#output-clientside',
'Client says "Callbacks"'
)
dashr.wait_for_text_to_equal(
"#output-serverside",
"Server says Callbacks"
)