Skip to content

Commit d20daa8

Browse files
authored
Provide support for multiple outputs (#119)
1 parent 5c83f5c commit d20daa8

File tree

8 files changed

+444
-28
lines changed

8 files changed

+444
-28
lines changed

.circleci/config.yml

+7-6
Original file line numberDiff line numberDiff line change
@@ -26,14 +26,10 @@ jobs:
2626
- run:
2727
name: 🚧 install R dependencies
2828
command: |
29-
sudo Rscript -e 'install.packages("remotes")'
30-
sudo R -e "remotes::install_github('plotly/dash-core-components', dependencies=TRUE)"
31-
sudo R -e "remotes::install_github('plotly/dash-html-components', dependencies=TRUE)"
32-
sudo R -e "remotes::install_github('plotly/dash-table', dependencies=TRUE)"
33-
sudo R CMD INSTALL .
29+
sudo Rscript -e 'install.packages("remotes"); remotes::install_github("plotly/dashR", dependencies=TRUE, upgrade=TRUE); install.packages(".", type="source", repos=NULL)'
3430
3531
- run:
36-
name: ⚙️ run integration test
32+
name: ⚙️ Integration tests
3733
command: |
3834
python -m venv venv
3935
. venv/bin/activate
@@ -42,6 +38,11 @@ jobs:
4238
export PATH=$PATH:/home/circleci/.local/bin/
4339
pytest tests/integration/
4440
41+
- run:
42+
name: 🔎 Unit tests
43+
command: |
44+
sudo Rscript -e 'testthat::test_dir("tests/")'
45+
4546
workflows:
4647
version: 2
4748
build:

NAMESPACE

+1
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
S3method(print,dash_component)
44
export(Dash)
55
export(dashNoUpdate)
6+
export(createCallbackId)
67
export(input)
78
export(output)
89
export(state)

R/dash.R

+47-17
Original file line numberDiff line numberDiff line change
@@ -196,7 +196,6 @@ Dash <- R6::R6Class(
196196

197197
dash_layout <- paste0(self$config$routes_pathname_prefix, "_dash-layout")
198198
route$add_handler("get", dash_layout, function(request, response, keys, ...) {
199-
200199
rendered_layout <- private$layout_render()
201200
# pass the layout on to encode_plotly in case there are dccGraph
202201
# components which include Plotly.js figures for which we'll need to
@@ -210,7 +209,6 @@ Dash <- R6::R6Class(
210209

211210
dash_deps <- paste0(self$config$routes_pathname_prefix, "_dash-dependencies")
212211
route$add_handler("get", dash_deps, function(request, response, keys, ...) {
213-
214212
# dash-renderer wants an empty array when no dependencies exist (see python/01.py)
215213
if (!length(private$callback_map)) {
216214
response$body <- to_JSON(list())
@@ -222,7 +220,7 @@ Dash <- R6::R6Class(
222220
payload <- Map(function(callback_signature) {
223221
list(
224222
inputs=callback_signature$inputs,
225-
output=paste0(callback_signature$output, collapse="."),
223+
output=createCallbackId(callback_signature$output),
226224
state=callback_signature$state
227225
)
228226
}, private$callback_map)
@@ -303,14 +301,46 @@ Dash <- R6::R6Class(
303301
# run plotly_build from the plotly package
304302
output_value <- encode_plotly(output_value)
305303

306-
# have to format the response body like this
307-
# https://github.com/plotly/dash/blob/064c811d/dash/dash.py#L562-L584
308-
resp <- list(
309-
response = list(
310-
props = setNames(list(output_value), gsub( "(^.+)(\\.)", "", request$body$output))
311-
)
312-
)
304+
# for multiple outputs, have to format the response body like this, including 'multi' key:
305+
# https://github.com/plotly/dash/blob/d9ddc877d6b15d9354bcef4141acca5d5fe6c07b/dash/dash.py#L1174-L1209
306+
307+
# for single outputs, the response body is formatted slightly differently:
308+
# https://github.com/plotly/dash/blob/d9ddc877d6b15d9354bcef4141acca5d5fe6c07b/dash/dash.py#L1210-L1220
313309

310+
if (substr(request$body$output, 1, 2) == '..') {
311+
# omit return objects of class "no_update" from output_value
312+
updatable_outputs <- "no_update" != vapply(output_value, class, character(1))
313+
output_value <- output_value[updatable_outputs]
314+
315+
# if multi-output callback, isolate the output IDs and properties
316+
ids <- getIdProps(request$body$output)$ids[updatable_outputs]
317+
props <- getIdProps(request$body$output)$props[updatable_outputs]
318+
319+
# prepare a response object which has list elements corresponding to ids
320+
# which themselves contain named list elements corresponding to props
321+
# then fill in nested list elements based on output_value
322+
323+
allprops <- setNames(vector("list", length(unique(ids))), unique(ids))
324+
325+
idmap <- setNames(ids, props)
326+
327+
for (id in unique(ids)) {
328+
allprops[[id]] <- output_value[grep(id, ids)]
329+
names(allprops[[id]]) <- names(idmap[which(idmap==id)])
330+
}
331+
332+
resp <- list(
333+
response = allprops,
334+
multi = TRUE
335+
)
336+
} else {
337+
resp <- list(
338+
response = list(
339+
props = setNames(list(output_value), gsub( "(^.+)(\\.)", "", request$body$output))
340+
)
341+
)
342+
}
343+
314344
response$body <- to_JSON(resp)
315345
response$status <- 200L
316346
response$type <- 'json'
@@ -504,14 +534,14 @@ Dash <- R6::R6Class(
504534

505535
inputs <- params[vapply(params, function(x) 'input' %in% attr(x, "class"), FUN.VALUE=logical(1))]
506536
state <- params[vapply(params, function(x) 'state' %in% attr(x, "class"), FUN.VALUE=logical(1))]
507-
537+
508538
# register the callback_map
509-
private$callback_map[[paste(output$id, output$property, sep='.')]] <- list(
510-
inputs=inputs,
511-
output=output,
512-
state=state,
513-
func=func
514-
)
539+
private$callback_map <- insertIntoCallbackMap(private$callback_map,
540+
inputs,
541+
output,
542+
state,
543+
func)
544+
515545
},
516546

517547
# ------------------------------------------------------------------------

R/utils.R

+78-5
Original file line numberDiff line numberDiff line change
@@ -138,7 +138,7 @@ render_dependencies <- function(dependencies, local = TRUE, prefix=NULL) {
138138

139139
# According to Dash convention, label react and react-dom as originating
140140
# in dash_renderer package, even though all three are currently served
141-
# u p from the DashR package
141+
# up from the DashR package
142142
if (dep$name %in% c("react", "react-dom", "prop-types")) {
143143
dep$name <- "dash-renderer"
144144
}
@@ -352,14 +352,38 @@ clean_dependencies <- function(deps) {
352352
return(deps_with_file)
353353
}
354354

355+
insertIntoCallbackMap <- function(map, inputs, output, state, func) {
356+
map[[createCallbackId(output)]] <- list(inputs=inputs,
357+
output=output,
358+
state=state,
359+
func=func
360+
)
361+
if (length(map) >= 2) {
362+
ids <- lapply(names(map), function(x) dash:::getIdProps(x)$ids)
363+
props <- lapply(names(map), function(x) dash:::getIdProps(x)$props)
364+
365+
outputs_as_list <- mapply(paste, ids, props, sep=".", SIMPLIFY = FALSE)
366+
367+
if (length(Reduce(intersect, outputs_as_list))) {
368+
stop(sprintf("One or more outputs are duplicated across callbacks. Please ensure that all ID and property combinations are unique."), call. = FALSE)
369+
}
370+
}
371+
return(map)
372+
}
373+
355374
assert_valid_callbacks <- function(output, params, func) {
356375
inputs <- params[vapply(params, function(x) 'input' %in% attr(x, "class"), FUN.VALUE=logical(1))]
357376
state <- params[vapply(params, function(x) 'state' %in% attr(x, "class"), FUN.VALUE=logical(1))]
358-
377+
359378
invalid_params <- vapply(params, function(x) {
360379
!any(c('input', 'state') %in% attr(x, "class"))
361380
}, FUN.VALUE=logical(1))
362381

382+
# Verify that no outputs are duplicated
383+
if (length(output) != length(unique(output))) {
384+
stop(sprintf("One or more callback outputs have been duplicated; please confirm that all outputs are unique."), call. = FALSE)
385+
}
386+
363387
# Verify that params contains no elements that are not either members of 'input' or 'state' classes
364388
if (any(invalid_params)) {
365389
stop(sprintf("Callback parameters must be inputs or states. Please verify formatting of callback parameters."), call. = FALSE)
@@ -371,10 +395,22 @@ assert_valid_callbacks <- function(output, params, func) {
371395
}
372396

373397
# Assert that the component ID as passed is a string.
374-
if(!(is.character(output$id) & !grepl("^\\s*$", output$id) & !grepl("\\.", output$id))) {
375-
stop(sprintf("Callback IDs must be (non-empty) character strings that do not contain one or more dots/periods. Please verify that the component ID is valid."), call. = FALSE)
398+
# This function inspects the output object to see if its ID
399+
# is a valid string.
400+
validateOutput <- function(string) {
401+
return((is.character(string[["id"]]) & !grepl("^\\s*$", string[["id"]]) & !grepl("\\.", string[["id"]])))
376402
}
377403

404+
# Check if the callback uses multiple outputs
405+
if (any(sapply(output, is.list))) {
406+
invalid_callback_ID <- (!all(vapply(output, validateOutput, logical(1))))
407+
} else {
408+
invalid_callback_ID <- (!validateOutput(output))
409+
}
410+
if (invalid_callback_ID) {
411+
stop(sprintf("Callback IDs must be (non-empty) character strings that do not contain one or more dots/periods. Please verify that the component ID is valid."), call. = FALSE)
412+
}
413+
378414
# Assert that user_function is a valid function
379415
if(!(is.function(func))) {
380416
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)
@@ -397,7 +433,22 @@ assert_valid_callbacks <- function(output, params, func) {
397433

398434
# Check that outputs are not inputs
399435
# https://github.com/plotly/dash/issues/323
400-
inputs_vs_outputs <- lapply(inputs, function(x) identical(x, output))
436+
437+
# helper function to permit same mapply syntax regardless
438+
# of whether output is defined using output function or not
439+
listWrap <- function(x){
440+
if (!any(sapply(x, is.list))) {
441+
return(list(x))
442+
} else {
443+
x
444+
}
445+
}
446+
447+
# determine whether any input matches the output, or outputs, if
448+
# multiple callback scenario
449+
inputs_vs_outputs <- mapply(function(inputObject, outputObject) {
450+
identical(outputObject[["id"]], inputObject[["id"]]) & identical(outputObject[["property"]], inputObject[["property"]])
451+
}, inputs, listWrap(output))
401452

402453
if(TRUE %in% inputs_vs_outputs) {
403454
stop(sprintf("Circular input and output arguments were found. Please verify that callback outputs are not also input arguments."), call. = FALSE)
@@ -828,3 +879,25 @@ getDashMetadata <- function(pkgname) {
828879
metadataFn <- as.vector(fnList[grepl("^\\.dash.+_js_metadata$", fnList)])
829880
return(metadataFn)
830881
}
882+
883+
createCallbackId <- function(output) {
884+
# check if callback uses single output
885+
if (!any(sapply(output, is.list))) {
886+
id <- paste0(output, collapse=".")
887+
} else {
888+
# multi-output callback, concatenate
889+
ids <- vapply(output, function(x) {
890+
paste(x, collapse = ".")
891+
}, character(1))
892+
id <- paste0("..", paste0(ids, collapse="..."), "..")
893+
}
894+
return(id)
895+
}
896+
897+
getIdProps <- function(output) {
898+
output_ids <- strsplit(substr(output, 3, nchar(output)-2), '...', fixed=TRUE)
899+
idprops <- lapply(output_ids, strsplit, '.', fixed=TRUE)
900+
ids <- vapply(unlist(idprops, recursive=FALSE), '[', character(1), 1)
901+
props <- vapply(unlist(idprops, recursive=FALSE), '[', character(1), 2)
902+
return(list(ids=ids, props=props))
903+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
from selenium.webdriver.support.select import Select
2+
3+
app = """
4+
library(dash)
5+
library(dashHtmlComponents)
6+
library(dashCoreComponents)
7+
library(plotly)
8+
library(dashTable)
9+
10+
app <- Dash$new()
11+
app$layout(
12+
htmlDiv(list(
13+
htmlDiv(list(
14+
htmlH1('Multi output example'),
15+
dccDropdown(id='data-dropdown',
16+
options = list(
17+
list(label = 'Movies',
18+
value = 'movies'),
19+
list(label = 'Series',
20+
value = 'series')
21+
),
22+
value = 'movies')
23+
),
24+
id = 'container',
25+
style = list(
26+
backgroundColor = '#ff998a'
27+
)
28+
),
29+
htmlDiv(list(
30+
htmlH2('Make a selection from the dropdown menu.',
31+
id = 'text-box'),
32+
dccRadioItems(id='radio-partial',
33+
options = list(
34+
list(label = 'All',
35+
value = 'all'),
36+
list(label = 'Do not update colour',
37+
value = 'static')
38+
),
39+
value = 'all')
40+
)
41+
)
42+
)
43+
)
44+
)
45+
app$callback(output=list(
46+
output(id='text-box', property='children'),
47+
output(id='container', property='style')
48+
),
49+
params=list(
50+
input(id='data-dropdown', property='value'),
51+
input(id='radio-partial', property='value')
52+
),
53+
function(value, choice) {
54+
if (is.null(value)) {
55+
return(dashNoUpdate())
56+
}
57+
58+
if (choice == "all" && value == "series") {
59+
style <- list(
60+
backgroundColor = '#ff998a'
61+
)
62+
} else if (choice == "all") {
63+
style <- list(
64+
backgroundColor = '#fff289'
65+
)
66+
} else {
67+
return(list(sprintf("You have chosen %s.", value),
68+
dashNoUpdate()))
69+
}
70+
71+
return(list(sprintf("You have chosen %s.", value),
72+
style))
73+
}
74+
)
75+
app$run_server(debug=TRUE)
76+
"""
77+
78+
79+
def test_rsnu001_multiple_outputs(dashr):
80+
dashr.start_server(app)
81+
dashr.find_element("#data-dropdown").click()
82+
dashr.find_elements("div.VirtualizedSelectOption")[1].click()
83+
dashr.wait_for_text_to_equal(
84+
"#text-box",
85+
"You have chosen series."
86+
)
87+
backgroundColor = dashr.find_element('#container').value_of_css_property("background-color")
88+
assert backgroundColor == "rgba(255, 153, 138, 1)"
89+
dashr.find_element("#data-dropdown").click()
90+
dashr.find_elements("div.VirtualizedSelectOption")[0].click()
91+
dashr.wait_for_text_to_equal(
92+
"#text-box",
93+
"You have chosen movies."
94+
)
95+
backgroundColor = dashr.find_element('#container').value_of_css_property("background-color")
96+
assert backgroundColor == "rgba(255, 242, 137, 1)"
97+
dashr.find_elements("input[type='radio']")[1].click()
98+
dashr.find_element("#data-dropdown").click()
99+
dashr.find_elements("div.VirtualizedSelectOption")[1].click()
100+
dashr.wait_for_text_to_equal(
101+
"#text-box",
102+
"You have chosen series."
103+
)
104+
assert backgroundColor == "rgba(255, 242, 137, 1)"
105+
dashr.find_elements("input[type='radio']")[0].click()
106+
dashr.find_element("#data-dropdown").click()
107+
dashr.find_elements("div.VirtualizedSelectOption")[0].click()
108+
dashr.wait_for_text_to_equal(
109+
"#text-box",
110+
"You have chosen movies."
111+
)
112+
assert backgroundColor == "rgba(255, 242, 137, 1)"

0 commit comments

Comments
 (0)