Skip to content

Commit 3b54773

Browse files
HammadTheOnerpkylenicolaskruchtenrpkyle
authored
189 - Add Pattern Matching Callbacks for Dash R (#228)
* Testing initial implementation * More testing * Callback Context Updates * Updating callback context logic * Fixing callback returns * Adding callback args conditional * Cleanup and additional changes to callback value conditionals * Comment cleanup * Added PMC callback validation, removed unnecessary code * Update R/dependencies.R Co-authored-by: Ryan Patrick Kyle <[email protected]> * Update R/dependencies.R Co-authored-by: Ryan Patrick Kyle <[email protected]> * Update R/dependencies.R Co-authored-by: Ryan Patrick Kyle <[email protected]> * Update R/dependencies.R Co-authored-by: Ryan Patrick Kyle <[email protected]> * Added build to gitignore * Updated dependencies.R * Update boilerplate docs and add wildcard symbols * Drying up validation code and applying symbol logic * Update test to use symbols * Cleaned up code and added allsmaller test example * Cleaning up redundant code * Update FUNDING.yml * Updated callback_args logic and example * Adding basic unittests, updated validation * Fixed response for MATCH callbacks * Added integration test and updated examples for docs * Added additional integration test * Formatting and cleanup * update docs * Update to-do app * Add comments to examples * Change empy vector to character type. Co-authored-by: Ryan Patrick Kyle <[email protected]> * Update boilerplate text. Co-authored-by: Ryan Patrick Kyle <[email protected]> * Update tests/integration/callbacks/test_pattern_matching.py Co-authored-by: Ryan Patrick Kyle <[email protected]> * Update tests/integration/callbacks/test_pattern_matching.py Co-authored-by: Ryan Patrick Kyle <[email protected]> * Update tests/integration/callbacks/test_pattern_matching.py Co-authored-by: Ryan Patrick Kyle <[email protected]> * Update tests/integration/callbacks/test_pattern_matching.py Co-authored-by: Ryan Patrick Kyle <[email protected]> * Update tests/integration/callbacks/test_pattern_matching.py Co-authored-by: Ryan Patrick Kyle <[email protected]> * Update tests/testthat/test-wildcards.R Co-authored-by: Ryan Patrick Kyle <[email protected]> * Update wildcards_test.R Co-authored-by: Ryan Patrick Kyle <[email protected]> * Update wildcards_test.R Co-authored-by: Ryan Patrick Kyle <[email protected]> * Update wildcards_test.R Co-authored-by: Ryan Patrick Kyle <[email protected]> * Update wildcards_test.R Co-authored-by: Ryan Patrick Kyle <[email protected]> * Update wildcards_test.R Co-authored-by: Ryan Patrick Kyle <[email protected]> * Update wildcards_test.R Co-authored-by: Ryan Patrick Kyle <[email protected]> * Update wildcards_test.R Co-authored-by: Ryan Patrick Kyle <[email protected]> * Update wildcards_test.R Co-authored-by: Ryan Patrick Kyle <[email protected]> * Removed triple colon syntax * Use seq_along and remove unnecessary unittest * Update CHANGELOG.md * Update CHANGELOG.md * Add support for arbitrary and sorted keys * Whitespace deleted * Added integration tests * Fixing test output * Fixing flakiness * Update test_pattern_matching.py * Update test_pattern_matching.py * Updating boilerplate text and test with generalized keys * Minor test fixes Co-authored-by: Ryan Patrick Kyle <[email protected]> Co-authored-by: Nicolas Kruchten <[email protected]> Co-authored-by: rpkyle <[email protected]>
1 parent 766e3a8 commit 3b54773

File tree

11 files changed

+646
-22
lines changed

11 files changed

+646
-22
lines changed

.github/FUNDING.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1 +1,2 @@
1+
github: plotly
12
custom: https://plotly.com/products/consulting-and-oem/

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,3 +7,4 @@ node_modules/
77
python/
88
todo.txt
99
r-finance*
10+
build/

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ This project adheres to [Semantic Versioning](http://semver.org/).
44

55
## [Unreleased]
66
### Added
7+
- Pattern-matching IDs and callbacks. Component IDs can be lists, and callbacks can reference patterns of components, using three different wildcards: `ALL`, `MATCH`, and `ALLSMALLER`. This lets you create components on demand, and have callbacks respond to any and all of them. To help with this, `app$callback_context` gets three new entries: `outputs_list`, `inputs_list`, and `states_list`, which contain all the ids, properties, and except for the outputs, the property values from all matched components. [#228](https://github.com/plotly/dashR/pull/228)
78
- New and improved callback graph in the debug menu. Now based on Cytoscape for much more interactivity, plus callback profiling including number of calls, fine-grained time information, bytes sent and received, and more. You can even add custom timing information on the server with `callback_context.record_timing(name, duration, description)` [#224](https://github.com/plotly/dashR/pull/224)
89
- Support for setting attributes on `external_scripts` and `external_stylesheets`, and validation for the parameters passed (attributes are verified, and elements that are lists themselves must be named). [#226](https://github.com/plotly/dashR/pull/226)
910
- Dash for R now supports user-defined routes and redirects via the `app$server_route` and `app$redirect` methods. [#225](https://github.com/plotly/dashR/pull/225)

NAMESPACE

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,10 @@
11
# Generated by roxygen2: do not edit by hand
22

33
S3method(print,dash_component)
4+
export(ALL)
5+
export(ALLSMALLER)
46
export(Dash)
7+
export(MATCH)
58
export(clientsideFunction)
69
export(dashNoUpdate)
710
export(input)

R/dash.R

Lines changed: 63 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -221,18 +221,38 @@ Dash <- R6::R6Class(
221221
callback_args <- list()
222222

223223
for (input_element in request$body$inputs) {
224-
if(is.null(input_element$value))
224+
if (any(grepl("id.", names(unlist(input_element))))) {
225+
if (!is.null(input_element$id)) input_element <- list(input_element)
226+
values <- character(0)
227+
for (wildcard_input in input_element) {
228+
values <- c(values, wildcard_input$value)
229+
}
230+
callback_args <- c(callback_args, ifelse(length(values), list(values), list(NULL)))
231+
}
232+
else if(is.null(input_element$value)) {
225233
callback_args <- c(callback_args, list(list(NULL)))
226-
else
234+
}
235+
else {
227236
callback_args <- c(callback_args, list(input_element$value))
237+
}
228238
}
229239

230240
if (length(request$body$state)) {
231241
for (state_element in request$body$state) {
232-
if(is.null(state_element$value))
242+
if (any(grepl("id.", names(unlist(state_element))))) {
243+
if (!is.null(state_element$id)) state_element <- list(state_element)
244+
values <- character(0)
245+
for (wildcard_state in state_element) {
246+
values <- c(values, wildcard_state$value)
247+
}
248+
callback_args <- c(callback_args, ifelse(length(values), list(values), list(NULL)))
249+
}
250+
else if(is.null(state_element$value)) {
233251
callback_args <- c(callback_args, list(list(NULL)))
234-
else
252+
}
253+
else {
235254
callback_args <- c(callback_args, list(state_element$value))
255+
}
236256
}
237257
}
238258

@@ -290,6 +310,12 @@ Dash <- R6::R6Class(
290310
response = allprops,
291311
multi = TRUE
292312
)
313+
} else if (is.list(request$body$outputs$id)) {
314+
props = setNames(list(output_value), gsub( "(^.+)(\\.)", "", request$body$output))
315+
resp <- list(
316+
response = setNames(list(props), to_JSON(request$body$outputs$id)),
317+
multi = TRUE
318+
)
293319
} else {
294320
resp <- list(
295321
response = list(
@@ -770,12 +796,43 @@ Dash <- R6::R6Class(
770796
#' containing valid JavaScript, or a call to [clientsideFunction],
771797
#' including `namespace` and `function_name` arguments for a locally served
772798
#' JavaScript function.
799+
#'
800+
#'
801+
#' For pattern-matching callbacks, the `id` field of a component is written
802+
#' in JSON-like syntax which describes a dictionary object when serialized
803+
#' for consumption by the Dash renderer. The fields are arbitrary keys
804+
#' , which describe the targets of the callback.
805+
#'
806+
#' For example, when we write `input(id=list("foo" = ALL, "bar" = "dropdown")`,
807+
#' Dash interprets this as "match any input that has an ID list where 'foo'
808+
#' is 'ALL' and 'bar' is anything." If any of the dropdown
809+
#' `value` properties change, all of their values are returned to the callback.
810+
#'
811+
#' However, for readability, we recommend using keys like type, index, or id.
812+
#' `type` can be used to refer to the class or set of dynamic components and
813+
#' `index` or `id` could be used to refer to the component you are matching
814+
#' within that set. While your application may have a single set of dynamic
815+
#' components, it's possible to specify multiple sets of dynamic components
816+
#' in more complex apps or if you are using `MATCH`.
817+
#'
818+
#' Like `ALL`, `MATCH` will fire the callback when any of the component's properties
819+
#' change. However, instead of passing all of the values into the callback, `MATCH`
820+
#' will pass just a single value into the callback. Instead of updating a single
821+
#' output, it will update the dynamic output that is "matched" with.
822+
#'
823+
#' `ALLSMALLER` is used to pass in the values of all of the targeted components
824+
#' on the page that have an index smaller than the index corresponding to the div.
825+
#' For example, `ALLSMALLER` makes it possible to filter results that are
826+
#' increasingly specific as the user applies each additional selection.
827+
#'
828+
#' `ALLSMALLER` can only be used in `input` and `state` items, and must be used
829+
#' on a key that has `MATCH` in the `output` item(s). `ALLSMALLER` it isn't always
830+
#' necessary (you can usually use `ALL` and filter out the indices in your callback),
831+
#' but it will make your logic simpler.
773832
callback = function(output, params, func) {
774833
assert_valid_callbacks(output, params, func)
775-
776834
inputs <- params[vapply(params, function(x) 'input' %in% attr(x, "class"), FUN.VALUE=logical(1))]
777835
state <- params[vapply(params, function(x) 'state' %in% attr(x, "class"), FUN.VALUE=logical(1))]
778-
779836
if (is.function(func)) {
780837
clientside_function <- NULL
781838
} else if (is.character(func)) {

R/dependencies.R

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,15 @@
11
# akin to https://github.com/plotly/dash/blob/d2ebc837/dash/dependencies.py
22

3+
# Helper functions for handling dependency ids or props
4+
setWildcardId <- function(id) {
5+
# Sort the keys of a wildcard id
6+
id <- id[order(names(id))]
7+
all_selectors <- vapply(id, function(x) {is.symbol(x)}, logical(1))
8+
id[all_selectors] <- as.character(id[all_selectors])
9+
id[!all_selectors] <- lapply(id[!all_selectors], function(x) {jsonlite::unbox(x)})
10+
return(as.character(jsonlite::toJSON(id, auto_unbox = FALSE)))
11+
}
12+
313
#' Input/Output/State definitions
414
#'
515
#' Use in conjunction with the `callback()` method from the [dash::Dash] class
@@ -8,13 +18,23 @@
818
#' The `dashNoUpdate()` function permits application developers to prevent a
919
#' single output from updating the layout. It has no formal arguments.
1020
#'
21+
#' `ALL`, `ALLSMALLER` and `MATCH` are symbols corresponding to the
22+
#' pattern-matching callback selectors with the same names. These allow you
23+
#' to write callbacks that respond to or update an arbitrary or dynamic
24+
#' number of components. For more information, see the `callback` section
25+
#' in \link{dash}.
26+
#'
1127
#' @name dependencies
1228
#' @param id a component id
1329
#' @param property the component property to use
1430

1531
#' @rdname dependencies
1632
#' @export
33+
1734
output <- function(id, property) {
35+
if (is.list(id)) {
36+
id = setWildcardId(id)
37+
}
1838
structure(
1939
dependency(id, property),
2040
class = c("dash_dependency", "output")
@@ -24,6 +44,9 @@ output <- function(id, property) {
2444
#' @rdname dependencies
2545
#' @export
2646
input <- function(id, property) {
47+
if (is.list(id)) {
48+
id = setWildcardId(id)
49+
}
2750
structure(
2851
dependency(id, property),
2952
class = c("dash_dependency", "input")
@@ -33,6 +56,9 @@ input <- function(id, property) {
3356
#' @rdname dependencies
3457
#' @export
3558
state <- function(id, property) {
59+
if (is.list(id)) {
60+
id = setWildcardId(id)
61+
}
3662
structure(
3763
dependency(id, property),
3864
class = c("dash_dependency", "state")
@@ -41,6 +67,9 @@ state <- function(id, property) {
4167

4268
dependency <- function(id = NULL, property = NULL) {
4369
if (is.null(id)) stop("Must specify an id", call. = FALSE)
70+
if (is.list(id)) {
71+
id = setWildcardId(id)
72+
}
4473
list(
4574
id = id,
4675
property = property
@@ -54,3 +83,15 @@ dashNoUpdate <- function() {
5483
class(x) <- "no_update"
5584
return(x)
5685
}
86+
87+
#' @rdname dependencies
88+
#' @export
89+
ALL <- as.symbol("ALL")
90+
91+
#' @rdname dependencies
92+
#' @export
93+
ALLSMALLER <- as.symbol("ALLSMALLER")
94+
95+
#' @rdname dependencies
96+
#' @export
97+
MATCH <- as.symbol("MATCH")

R/utils.R

Lines changed: 101 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -290,6 +290,22 @@ assert_no_names <- function (x)
290290
paste(nms, collapse = "', '")), call. = FALSE)
291291
}
292292

293+
assertValidWildcards <- function(dependency) {
294+
if (is.symbol(dependency$id)) {
295+
result <- (jsonlite::validate(as.character(dependency$id)) && grepl("{", dependency$id))
296+
} else {
297+
result <- TRUE
298+
}
299+
if (!result) {
300+
dependencyType <- class(dependency)
301+
stop(sprintf("A callback %s ID contains restricted pattern matching callback selectors ALL, MATCH or ALLSMALLER. Please verify that it is formatted as a pattern matching callback list ID, or choose a different component ID.",
302+
dependencyType[dependencyType %in% c("input", "output", "state")]),
303+
call. = FALSE)
304+
} else {
305+
return(result)
306+
}
307+
}
308+
293309
# the following function attempts to prune remote CSS
294310
# or local CSS/JS dependencies that either should not
295311
# be resolved to local R package paths, or which have
@@ -403,6 +419,27 @@ assert_valid_callbacks <- function(output, params, func) {
403419
stop(sprintf("The callback method requires that one or more properly formatted inputs are passed."), call. = FALSE)
404420
}
405421

422+
# Verify that 'input', 'state' and 'output' parameters only contain 'Wildcard' keywords if they are JSON formatted ids for pattern matching callbacks
423+
valid_wildcard_inputs <- sapply(inputs, function(x) {
424+
assertValidWildcards(x)
425+
})
426+
427+
428+
valid_wildcard_state <- sapply(state, function(x) {
429+
assertValidWildcards(x)
430+
})
431+
432+
if(any(sapply(output, is.list))) {
433+
valid_wildcard_output <- sapply(output, function(x) {
434+
assertValidWildcards(x)
435+
})
436+
} else {
437+
valid_wildcard_output <- sapply(list(output), function(x) {
438+
assertValidWildcards(x)
439+
})
440+
}
441+
442+
406443
# Check that outputs are not inputs
407444
# https://github.com/plotly/dash/issues/323
408445

@@ -987,29 +1024,78 @@ removeHandlers <- function(fnList) {
9871024
}
9881025

9891026
setCallbackContext <- function(callback_elements) {
990-
states <- lapply(callback_elements$states, function(x) {
991-
setNames(x$value, paste(x$id, x$property, sep="."))
992-
})
1027+
# Set state elements for this callback
1028+
1029+
if (length(callback_elements$state[[1]]) == 0) {
1030+
states <- sapply(callback_elements$state, function(x) {
1031+
setNames(list(x$value), paste(x$id, x$property, sep="."))
1032+
})
1033+
} else if (is.character(callback_elements$state[[1]][[1]])) {
1034+
states <- sapply(callback_elements$state, function(x) {
1035+
setNames(list(x$value), paste(x$id, x$property, sep="."))
1036+
})
1037+
} else {
1038+
states <- sapply(callback_elements$state, function(x) {
1039+
states_vector <- unlist(x)
1040+
setNames(list(states_vector[grepl("value|value.", names(states_vector))]),
1041+
paste(as.character(jsonlite::toJSON(x[[1]])), x$property, sep="."))
1042+
})
1043+
}
9931044

9941045
splitIdProp <- function(x) unlist(strsplit(x, split = "[.]"))
9951046

9961047
triggered <- lapply(callback_elements$changedPropIds,
9971048
function(x) {
9981049
input_id <- splitIdProp(x)[1]
9991050
prop <- splitIdProp(x)[2]
1000-
1001-
id_match <- vapply(callback_elements$inputs, function(x) x$id %in% input_id, logical(1))
1002-
prop_match <- vapply(callback_elements$inputs, function(x) x$property %in% prop, logical(1))
1003-
1004-
value <- sapply(callback_elements$inputs[id_match & prop_match], `[[`, "value")
1005-
1006-
list(`prop_id` = x, `value` = value)
1051+
1052+
# The following conditionals check whether the callback is a pattern-matching callback and if it has been triggered.
1053+
if (startsWith(input_id, "{")){
1054+
id_match <- vapply(callback_elements$inputs, function(x) {
1055+
x <- unlist(x)
1056+
any(x[grepl("id.", names(x))] %in% jsonlite::fromJSON(input_id)[[1]])
1057+
}, logical(1))[[1]]
1058+
} else {
1059+
id_match <- vapply(callback_elements$inputs, function(x) x$id %in% input_id, logical(1))
1060+
}
1061+
1062+
if (startsWith(input_id, "{")){
1063+
prop_match <- vapply(callback_elements$inputs, function(x) {
1064+
x <- unlist(x)
1065+
any(x[names(x) == "property"] %in% prop)
1066+
}, logical(1))[[1]]
1067+
} else {
1068+
prop_match <- vapply(callback_elements$inputs, function(x) x$property %in% prop, logical(1))
1069+
}
1070+
1071+
if (startsWith(input_id, "{")){
1072+
if (length(callback_elements$inputs) == 1 || !is.null(unlist(callback_elements$inputs, recursive = F)$value)) {
1073+
value <- sapply(callback_elements$inputs[id_match & prop_match], `[[`, "value")
1074+
} else {
1075+
value <- sapply(callback_elements$inputs[id_match & prop_match][[1]], `[[`, "value")
1076+
}
1077+
} else {
1078+
value <- sapply(callback_elements$inputs[id_match & prop_match], `[[`, "value")
1079+
}
1080+
1081+
return(list(`prop_id` = x, `value` = value))
10071082
}
1008-
)
1009-
1010-
inputs <- sapply(callback_elements$inputs, function(x) {
1011-
setNames(list(x$value), paste(x$id, x$property, sep="."))
1012-
})
1083+
)
1084+
if (length(callback_elements$inputs[[1]]) == 0 || is.character(callback_elements$inputs[[1]][[1]])) {
1085+
inputs <- sapply(callback_elements$inputs, function(x) {
1086+
setNames(list(x$value), paste(x$id, x$property, sep="."))
1087+
})
1088+
} else if (length(callback_elements$inputs[[1]]) > 1) {
1089+
inputs <- sapply(callback_elements$inputs, function(x) {
1090+
inputs_vector <- unlist(x)
1091+
setNames(list(inputs_vector[grepl("value|value.", names(inputs_vector))]), paste(as.character(jsonlite::toJSON(x$id)), x$property, sep="."))
1092+
})
1093+
} else {
1094+
inputs <- sapply(callback_elements$inputs, function(x) {
1095+
inputs_vector <- unlist(x)
1096+
setNames(list(inputs_vector[grepl("value|value.", names(inputs_vector))]), paste(as.character(jsonlite::toJSON(x[[1]]$id)), x[[1]]$property, sep="."))
1097+
})
1098+
}
10131099

10141100
return(list(states=states,
10151101
triggered=unlist(triggered, recursive=FALSE),

0 commit comments

Comments
 (0)