diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml index c834fa22..0712cd67 100644 --- a/.github/FUNDING.yml +++ b/.github/FUNDING.yml @@ -1 +1,2 @@ +github: plotly custom: https://plotly.com/products/consulting-and-oem/ diff --git a/.gitignore b/.gitignore index e6c7bbd5..3f2b95ac 100644 --- a/.gitignore +++ b/.gitignore @@ -7,3 +7,4 @@ node_modules/ python/ todo.txt r-finance* +build/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 99df24ef..2f019a22 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ This project adheres to [Semantic Versioning](http://semver.org/). ## [Unreleased] ### Added +- 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) - 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) - 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) - 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) diff --git a/NAMESPACE b/NAMESPACE index 8ac7711a..aeae30d4 100644 --- a/NAMESPACE +++ b/NAMESPACE @@ -1,7 +1,10 @@ # Generated by roxygen2: do not edit by hand S3method(print,dash_component) +export(ALL) +export(ALLSMALLER) export(Dash) +export(MATCH) export(clientsideFunction) export(dashNoUpdate) export(input) diff --git a/R/dash.R b/R/dash.R index e7b059ca..e7928541 100644 --- a/R/dash.R +++ b/R/dash.R @@ -221,18 +221,38 @@ Dash <- R6::R6Class( callback_args <- list() for (input_element in request$body$inputs) { - if(is.null(input_element$value)) + if (any(grepl("id.", names(unlist(input_element))))) { + if (!is.null(input_element$id)) input_element <- list(input_element) + values <- character(0) + for (wildcard_input in input_element) { + values <- c(values, wildcard_input$value) + } + callback_args <- c(callback_args, ifelse(length(values), list(values), list(NULL))) + } + else if(is.null(input_element$value)) { callback_args <- c(callback_args, list(list(NULL))) - else + } + else { callback_args <- c(callback_args, list(input_element$value)) + } } if (length(request$body$state)) { for (state_element in request$body$state) { - if(is.null(state_element$value)) + if (any(grepl("id.", names(unlist(state_element))))) { + if (!is.null(state_element$id)) state_element <- list(state_element) + values <- character(0) + for (wildcard_state in state_element) { + values <- c(values, wildcard_state$value) + } + callback_args <- c(callback_args, ifelse(length(values), list(values), list(NULL))) + } + else if(is.null(state_element$value)) { callback_args <- c(callback_args, list(list(NULL))) - else + } + else { callback_args <- c(callback_args, list(state_element$value)) + } } } @@ -290,6 +310,12 @@ Dash <- R6::R6Class( response = allprops, multi = TRUE ) + } else if (is.list(request$body$outputs$id)) { + props = setNames(list(output_value), gsub( "(^.+)(\\.)", "", request$body$output)) + resp <- list( + response = setNames(list(props), to_JSON(request$body$outputs$id)), + multi = TRUE + ) } else { resp <- list( response = list( @@ -770,12 +796,43 @@ Dash <- R6::R6Class( #' containing valid JavaScript, or a call to [clientsideFunction], #' including `namespace` and `function_name` arguments for a locally served #' JavaScript function. + #' + #' + #' For pattern-matching callbacks, the `id` field of a component is written + #' in JSON-like syntax which describes a dictionary object when serialized + #' for consumption by the Dash renderer. The fields are arbitrary keys + #' , which describe the targets of the callback. + #' + #' For example, when we write `input(id=list("foo" = ALL, "bar" = "dropdown")`, + #' Dash interprets this as "match any input that has an ID list where 'foo' + #' is 'ALL' and 'bar' is anything." If any of the dropdown + #' `value` properties change, all of their values are returned to the callback. + #' + #' However, for readability, we recommend using keys like type, index, or id. + #' `type` can be used to refer to the class or set of dynamic components and + #' `index` or `id` could be used to refer to the component you are matching + #' within that set. While your application may have a single set of dynamic + #' components, it's possible to specify multiple sets of dynamic components + #' in more complex apps or if you are using `MATCH`. + #' + #' Like `ALL`, `MATCH` will fire the callback when any of the component's properties + #' change. However, instead of passing all of the values into the callback, `MATCH` + #' will pass just a single value into the callback. Instead of updating a single + #' output, it will update the dynamic output that is "matched" with. + #' + #' `ALLSMALLER` is used to pass in the values of all of the targeted components + #' on the page that have an index smaller than the index corresponding to the div. + #' For example, `ALLSMALLER` makes it possible to filter results that are + #' increasingly specific as the user applies each additional selection. + #' + #' `ALLSMALLER` can only be used in `input` and `state` items, and must be used + #' on a key that has `MATCH` in the `output` item(s). `ALLSMALLER` it isn't always + #' necessary (you can usually use `ALL` and filter out the indices in your callback), + #' but it will make your logic simpler. callback = function(output, params, func) { assert_valid_callbacks(output, params, func) - 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 if (is.character(func)) { diff --git a/R/dependencies.R b/R/dependencies.R index 77ec8b77..66951fc2 100644 --- a/R/dependencies.R +++ b/R/dependencies.R @@ -1,5 +1,15 @@ # akin to https://github.com/plotly/dash/blob/d2ebc837/dash/dependencies.py +# Helper functions for handling dependency ids or props +setWildcardId <- function(id) { + # Sort the keys of a wildcard id + id <- id[order(names(id))] + all_selectors <- vapply(id, function(x) {is.symbol(x)}, logical(1)) + id[all_selectors] <- as.character(id[all_selectors]) + id[!all_selectors] <- lapply(id[!all_selectors], function(x) {jsonlite::unbox(x)}) + return(as.character(jsonlite::toJSON(id, auto_unbox = FALSE))) +} + #' Input/Output/State definitions #' #' Use in conjunction with the `callback()` method from the [dash::Dash] class @@ -8,13 +18,23 @@ #' The `dashNoUpdate()` function permits application developers to prevent a #' single output from updating the layout. It has no formal arguments. #' +#' `ALL`, `ALLSMALLER` and `MATCH` are symbols corresponding to the +#' pattern-matching callback selectors with the same names. These allow you +#' to write callbacks that respond to or update an arbitrary or dynamic +#' number of components. For more information, see the `callback` section +#' in \link{dash}. +#' #' @name dependencies #' @param id a component id #' @param property the component property to use #' @rdname dependencies #' @export + output <- function(id, property) { + if (is.list(id)) { + id = setWildcardId(id) + } structure( dependency(id, property), class = c("dash_dependency", "output") @@ -24,6 +44,9 @@ output <- function(id, property) { #' @rdname dependencies #' @export input <- function(id, property) { + if (is.list(id)) { + id = setWildcardId(id) + } structure( dependency(id, property), class = c("dash_dependency", "input") @@ -33,6 +56,9 @@ input <- function(id, property) { #' @rdname dependencies #' @export state <- function(id, property) { + if (is.list(id)) { + id = setWildcardId(id) + } structure( dependency(id, property), class = c("dash_dependency", "state") @@ -41,6 +67,9 @@ state <- function(id, property) { dependency <- function(id = NULL, property = NULL) { if (is.null(id)) stop("Must specify an id", call. = FALSE) + if (is.list(id)) { + id = setWildcardId(id) + } list( id = id, property = property @@ -54,3 +83,15 @@ dashNoUpdate <- function() { class(x) <- "no_update" return(x) } + +#' @rdname dependencies +#' @export +ALL <- as.symbol("ALL") + +#' @rdname dependencies +#' @export +ALLSMALLER <- as.symbol("ALLSMALLER") + +#' @rdname dependencies +#' @export +MATCH <- as.symbol("MATCH") diff --git a/R/utils.R b/R/utils.R index 92d6973a..74cbf2f4 100644 --- a/R/utils.R +++ b/R/utils.R @@ -290,6 +290,22 @@ assert_no_names <- function (x) paste(nms, collapse = "', '")), call. = FALSE) } +assertValidWildcards <- function(dependency) { + if (is.symbol(dependency$id)) { + result <- (jsonlite::validate(as.character(dependency$id)) && grepl("{", dependency$id)) + } else { + result <- TRUE + } + if (!result) { + dependencyType <- class(dependency) + 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.", + dependencyType[dependencyType %in% c("input", "output", "state")]), + call. = FALSE) + } else { + return(result) + } +} + # the following function attempts to prune remote CSS # or local CSS/JS dependencies that either should not # be resolved to local R package paths, or which have @@ -403,6 +419,27 @@ assert_valid_callbacks <- function(output, params, func) { stop(sprintf("The callback method requires that one or more properly formatted inputs are passed."), call. = FALSE) } + # Verify that 'input', 'state' and 'output' parameters only contain 'Wildcard' keywords if they are JSON formatted ids for pattern matching callbacks + valid_wildcard_inputs <- sapply(inputs, function(x) { + assertValidWildcards(x) + }) + + + valid_wildcard_state <- sapply(state, function(x) { + assertValidWildcards(x) + }) + + if(any(sapply(output, is.list))) { + valid_wildcard_output <- sapply(output, function(x) { + assertValidWildcards(x) + }) + } else { + valid_wildcard_output <- sapply(list(output), function(x) { + assertValidWildcards(x) + }) + } + + # Check that outputs are not inputs # https://github.com/plotly/dash/issues/323 @@ -987,9 +1024,23 @@ removeHandlers <- function(fnList) { } setCallbackContext <- function(callback_elements) { - states <- lapply(callback_elements$states, function(x) { - setNames(x$value, paste(x$id, x$property, sep=".")) - }) + # Set state elements for this callback + + if (length(callback_elements$state[[1]]) == 0) { + states <- sapply(callback_elements$state, function(x) { + setNames(list(x$value), paste(x$id, x$property, sep=".")) + }) + } else if (is.character(callback_elements$state[[1]][[1]])) { + states <- sapply(callback_elements$state, function(x) { + setNames(list(x$value), paste(x$id, x$property, sep=".")) + }) + } else { + states <- sapply(callback_elements$state, function(x) { + states_vector <- unlist(x) + setNames(list(states_vector[grepl("value|value.", names(states_vector))]), + paste(as.character(jsonlite::toJSON(x[[1]])), x$property, sep=".")) + }) + } splitIdProp <- function(x) unlist(strsplit(x, split = "[.]")) @@ -997,19 +1048,54 @@ setCallbackContext <- function(callback_elements) { function(x) { input_id <- splitIdProp(x)[1] prop <- splitIdProp(x)[2] - - id_match <- vapply(callback_elements$inputs, function(x) x$id %in% input_id, logical(1)) - prop_match <- vapply(callback_elements$inputs, function(x) x$property %in% prop, logical(1)) - - value <- sapply(callback_elements$inputs[id_match & prop_match], `[[`, "value") - - list(`prop_id` = x, `value` = value) + + # The following conditionals check whether the callback is a pattern-matching callback and if it has been triggered. + if (startsWith(input_id, "{")){ + id_match <- vapply(callback_elements$inputs, function(x) { + x <- unlist(x) + any(x[grepl("id.", names(x))] %in% jsonlite::fromJSON(input_id)[[1]]) + }, logical(1))[[1]] + } else { + id_match <- vapply(callback_elements$inputs, function(x) x$id %in% input_id, logical(1)) + } + + if (startsWith(input_id, "{")){ + prop_match <- vapply(callback_elements$inputs, function(x) { + x <- unlist(x) + any(x[names(x) == "property"] %in% prop) + }, logical(1))[[1]] + } else { + prop_match <- vapply(callback_elements$inputs, function(x) x$property %in% prop, logical(1)) + } + + if (startsWith(input_id, "{")){ + if (length(callback_elements$inputs) == 1 || !is.null(unlist(callback_elements$inputs, recursive = F)$value)) { + value <- sapply(callback_elements$inputs[id_match & prop_match], `[[`, "value") + } else { + value <- sapply(callback_elements$inputs[id_match & prop_match][[1]], `[[`, "value") + } + } else { + value <- sapply(callback_elements$inputs[id_match & prop_match], `[[`, "value") + } + + return(list(`prop_id` = x, `value` = value)) } - ) - - inputs <- sapply(callback_elements$inputs, function(x) { - setNames(list(x$value), paste(x$id, x$property, sep=".")) - }) + ) + if (length(callback_elements$inputs[[1]]) == 0 || is.character(callback_elements$inputs[[1]][[1]])) { + inputs <- sapply(callback_elements$inputs, function(x) { + setNames(list(x$value), paste(x$id, x$property, sep=".")) + }) + } else if (length(callback_elements$inputs[[1]]) > 1) { + inputs <- sapply(callback_elements$inputs, function(x) { + inputs_vector <- unlist(x) + setNames(list(inputs_vector[grepl("value|value.", names(inputs_vector))]), paste(as.character(jsonlite::toJSON(x$id)), x$property, sep=".")) + }) + } else { + inputs <- sapply(callback_elements$inputs, function(x) { + inputs_vector <- unlist(x) + setNames(list(inputs_vector[grepl("value|value.", names(inputs_vector))]), paste(as.character(jsonlite::toJSON(x[[1]]$id)), x[[1]]$property, sep=".")) + }) + } return(list(states=states, triggered=unlist(triggered, recursive=FALSE), diff --git a/man/Dash.Rd b/man/Dash.Rd index d8210223..acab9892 100644 --- a/man/Dash.Rd +++ b/man/Dash.Rd @@ -487,7 +487,39 @@ with its own defined \code{id} and \code{property}.} arguments. \code{func} may be any valid R function, or a character string containing valid JavaScript, or a call to \link{clientsideFunction}, including \code{namespace} and \code{function_name} arguments for a locally served -JavaScript function.} +JavaScript function. + +For pattern-matching callbacks, the \code{id} field of a component is written +in JSON-like syntax which describes a dictionary object when serialized +for consumption by the Dash renderer. The fields include a \code{type} and +an \code{index}, which describe the targets of the callback. + +For example, when we write \verb{input(id=list("index" = ALL, "type" = "filter-dropdown")}, +Dash interprets this as "match any input that has an ID list where 'type' +is 'filter-dropdown' and 'index' is anything." If any of the dropdown +\code{value} properties change, all of their values are returned to the callback. + +However, for readability, we recommend using keys like type, index, or id. +\code{type} can be used to refer to the class or set of dynamic components and +\code{index} or \code{id} could be used to refer which component you are matching +within that set. While your applicatoin may have a single set of dynamic +components, it's possible to specify multiple sets of dynamic components +in more complex apps or if you are using \code{MATCH}. + +Like \code{ALL}, \code{MATCH} will fire the callback when any of the component's properties +change. However, instead of passing all of the values into the callback, \code{MATCH} +will pass just a single value into the callback. Instead of updating a single +output, it will update the dynamic output that is "matched" with. + +\code{ALLSMALLER} is used to pass in the values of all of the targeted components +on the page that have an index smaller than the index corresponding to the div. +For example, \code{ALLSMALLER} makes it possible to filter results that are +increasingly specific as the user applies each additional selection. + +\code{ALLSMALLER} can only be used in \code{input} and \code{state} items, and must be used +on a key that has \code{MATCH} in the \code{output} item(s). \code{ALLSMALLER} it isn't always +necessary (you can usually use \code{ALL} and filter out the indices in your callback), +but it will make your logic simpler.} } \if{html}{\out{</div>}} } diff --git a/man/dependencies.Rd b/man/dependencies.Rd index 44f83721..86af65cc 100644 --- a/man/dependencies.Rd +++ b/man/dependencies.Rd @@ -1,12 +1,23 @@ % Generated by roxygen2: do not edit by hand % Please edit documentation in R/dependencies.R +\docType{data} \name{dependencies} \alias{dependencies} \alias{output} \alias{input} \alias{state} \alias{dashNoUpdate} +\alias{ALL} +\alias{ALLSMALLER} +\alias{MATCH} \title{Input/Output/State definitions} +\format{ +An object of class \code{name} of length 1. + +An object of class \code{name} of length 1. + +An object of class \code{name} of length 1. +} \usage{ output(id, property) @@ -15,6 +26,12 @@ input(id, property) state(id, property) dashNoUpdate() + +ALL + +ALLSMALLER + +MATCH } \arguments{ \item{id}{a component id} @@ -28,4 +45,11 @@ to define the update logic in your application. \details{ The \code{dashNoUpdate()} function permits application developers to prevent a single output from updating the layout. It has no formal arguments. + +\code{ALL}, \code{ALLSMALLER} and \code{MATCH} are symbols corresponding to the +pattern-matching callback selectors with the same names. These allow you +to write callbacks that respond to or update an arbitrary or dynamic +number of components. For more information, see the \code{callback} section +in \link{dash}. } +\keyword{datasets} diff --git a/tests/integration/callbacks/test_pattern_matching.py b/tests/integration/callbacks/test_pattern_matching.py new file mode 100644 index 00000000..e96b44e8 --- /dev/null +++ b/tests/integration/callbacks/test_pattern_matching.py @@ -0,0 +1,377 @@ +all_app = """ +library(dash) +library(dashHtmlComponents) +library(dashCoreComponents) + +app <- Dash$new() + +app$layout(htmlDiv(list( + htmlButton("Add Filter", id="add-filter", n_clicks=0), + htmlDiv(id="dropdown-container", children=list()), + htmlDiv(id="dropdown-container-output") +))) + + +app$callback( + output(id="dropdown-container", property = "children"), + params = list( + input(id = "add-filter", property = "n_clicks"), + state(id = "dropdown-container", property = "children") + ), + display_dropdowns <- function(n_clicks, children){ + new_dropdown = dccDropdown( + id=list( + "bar" = n_clicks, + "foo" = "filter-dropdown" + ), + options = lapply(c("NYC", "MTL", "LA", "TOKYO"), function(x){ + list("label" = x, "value" = x) + }) + ) + children[[n_clicks + 1]] <- new_dropdown + return(children) + } +) + + +app$callback( + output(id="dropdown-container-output", property="children"), + params = list( + input(id=list("bar" = ALL, "foo" = "filter-dropdown"), property= "value") + ), + display_output <- function(test){ + ctx <- app$callback_context() + return(htmlDiv( + lapply(1:length(test), function(x){ + return(htmlDiv(sprintf("Dropdown %s = %s", x, test[[x]]))) + }) + )) + } +) + + +app$run_server() +""" + +allsmaller_app = """ +library(dash) +library(dashCoreComponents) +library(dashHtmlComponents) + + +df <- read.csv('https://raw.githubusercontent.com/plotly/datasets/master/gapminder2007.csv', stringsAsFactors = FALSE) + + +app <- Dash$new() + +app$layout(htmlDiv(list( + htmlButton("Add Filter", id = "add-filter-ex3", n_clicks = 0), + htmlDiv(id = "container-ex3", children = list()) +))) + + +app$callback( + output('container-ex3', 'children'), + params = list( + input('add-filter-ex3', 'n_clicks'), + state('container-ex3', 'children') + ), + display_dropdowns <- function(n_clicks, existing_children){ + new_children <- htmlDiv(list( + dccDropdown( + id = list("index" = n_clicks, "type" = "filter-dropdown-ex3"), + options = lapply(unique(df$country), function(x){ + list("label" = x, "value" = x) + }), + value = unique(df$country)[n_clicks + 1] + ), + htmlDiv(id = list("index" = n_clicks, "type" = "output-ex3"), children = list(unique(df$country)[n_clicks + 1])) + )) + + existing_children <- c(existing_children, list(new_children)) + } +) + + +app$callback( + output(id = list("type" = "output-ex3", "index" = MATCH), property = "children"), + params = list( + input(id = list("type" = "filter-dropdown-ex3", "index" = MATCH), property = "value"), + input(id = list("type" = "filter-dropdown-ex3", "index" = ALLSMALLER), property = "value") + ), + display_output <- function(matching_value, previous_values){ + previous_values_in_reversed_order = rev(previous_values) + all_values = c(matching_value, previous_values_in_reversed_order) + all_values = unlist(all_values) + + dff = df[df$country %in% all_values,] + avgLifeExp = round(mean(dff$lifeExp), digits = 2) + + if (length(all_values) == 1) { + return( + htmlDiv(sprintf("%s is the life expectancy of %s.", avgLifeExp, matching_value)) + ) + } else if (length(all_values) == 2) { + return( + htmlDiv(sprintf("%s is the life expectancy of %s.", avgLifeExp, paste(all_values, collapse = " and ")), + id="test") + ) + } else { + return( + htmlDiv(sprintf("%s is the life expectancy of %s, and %s.", avgLifeExp, paste(all_values[-length(all_values)], + collapse = " , "), paste(all_values[length(all_values)]))) + ) + } + } +) + +app$run_server() +""" + +match_app = """ +library(dash) +library(dashCoreComponents) +library(dashHtmlComponents) + + +app <- Dash$new() + +app$layout(htmlDiv(list( + htmlButton("Add Filter", id="dynamic-add-filter", n_clicks=0), + htmlDiv(id="dynamic-dropdown-container", children = list()) +))) + + +app$callback( + output(id="dynamic-dropdown-container", "children"), + params = list( + input("dynamic-add-filter", "n_clicks"), + state("dynamic-dropdown-container", "children") + ), + display_dropdown <- function(n_clicks, children){ + new_element = htmlDiv(list( + dccDropdown( + id = list("index" = n_clicks, "type" = "dynamic-dropdown"), + options = lapply(c("NYC", "MTL", "LA", "TOKYO"), function(x){ + list("label" = x, "value" = x) + }) + ), + htmlDiv( + id = list("index" = n_clicks, "type" = "dynamic-output"), + children = list() + ) + )) + + children <- c(children, list(new_element)) + return(children) + } +) + + +app$callback( + output(id = list("index" = MATCH, "type" = "dynamic-output"), property= "children"), + params = list( + input(id=list("index" = MATCH, "type" = "dynamic-dropdown"), property= "value"), + state(id=list("index" = MATCH, "type" = "dynamic-dropdown"), property= "id") + ), + display_output <- function(value, id){ + return(htmlDiv(sprintf("Dropdown %s = %s", id$index, value))) + } +) + +app$run_server() +""" + +todo_app = """ +library(dash) +library(dashCoreComponents) +library(dashHtmlComponents) + +app <- Dash$new() + + +app$layout(htmlDiv(list( + htmlDiv('Dash To-Do List'), + dccInput(id = 'new-item'), + htmlButton("Add", id = "add"), + htmlButton("Clear Done", id = "clear-done"), + htmlDiv(id = "list-container"), + htmlDiv(id = "totals") +))) + + +style_todo <- list("display" = "inline", "margin" = "10px") +style_done <- list("display" = "inline", "margin" = "10px", "textDecoration" = "line-through", "color" = "#888") + + +app$callback( + output = list( + output("list-container", "children"), + output("new-item", "value") + ), + params = list( + input("add", "n_clicks"), + input("new-item", "n_submit"), + input("clear-done", "n_clicks"), + state("new-item", "value"), + state(list("index" = ALL, "type" = "check"), "children"), + state(list("index" = ALL, "type" = "done"), "value") + ), + edit_list <- function(add, add2, clear, new_item, items, items_done) { + ctx <- app$callback_context() + triggered <- ifelse(is.null(ctx$triggered$prop_id), " ", ctx$triggered$prop_id) + adding <- grepl(triggered, "add.n_clicks|new-item.n_submit") + clearing = grepl(triggered, "clear-done.n_clicks") + + # Create a list which we will hydrate with "items" and "items_done" + new_spec <- list() + for (i in seq_along(items)) { + if (!is.null(items[[i]])) { + new_spec[[length(new_spec) + 1]] <- list(items[[i]], list()) + } + } + + for (i in seq_along(items_done)) { + if (!is.null(items_done[[i]])) { + new_spec[[i]][[2]] <- items_done[[i]] + } + } + + # If clearing, we remove elements from the list which have been marked "done" + if (clearing) { + remove_vector <- c() + for (i in seq_along(new_spec)) { + if (length(new_spec[[i]][[2]]) > 0) { + remove_vector <- c(remove_vector, i) + } + } + new_spec <- new_spec[-remove_vector] + } + + # Add a new item to the list + if (adding) { + new_spec[[length(new_spec) + 1]] <- list(new_item, list()) + } + + # Generate dynamic components with pattern matching IDs + new_list <- list() + if (!is.null(unlist(new_spec))) { + for (i in seq_along(new_spec)) { + add_list <- list(htmlDiv(list( + dccChecklist( + id = list("index" = i, "type" = "done"), + options = list( + list("label" = "", "value" = "done") + ), + value = new_spec[[i]][[2]], + style = list("display" = "inline"), + labelStyle = list("display" = "inline") + ), + htmlDiv(new_spec[[i]][[1]], id = list("index" = i, "type" = "check"), style = if (length(new_spec[[i]][[2]]) == 0) style_todo else style_done) + ), style = list("clear" = "both"))) + new_list <- c(new_list, add_list) + } + return(list(new_list, "")) + } else { + return(list(list(), "")) + } + } +) + +app$callback( + output(id = list("index" = MATCH, "type" = "check"), property = "style"), + params = list( + input(id = list("index" = MATCH, "type" = "done"), property = "value") + ), + mark_done <- function(done){ + if (length(done[[1]] > 0)) return(style_done) else return(style_todo) + } +) + + +app$callback( + output(id = "totals", property = "children"), + params = list( + input(list("index" = ALL, "type" = "done"), property = "value"), + state(list("index" = ALL, "type" = "check"), property = "children") + ), + show_totals <- function(done, total) { + count_all = length(total) + count_done = length(done) + + result = sprintf("%s of %s items completed", count_done, count_all) + + if (count_all > 0) { + result = paste(result, sprintf(" - %s%%", as.integer(100 * count_done/count_all))) + } + + if (is.null(total[[1]])) { + return("Add an item to the list to get started.") + } else { + return(result) + } + } +) + + +app$run_server() +""" + + +def test_rpmc001_pattern_matching_all(dashr): + dashr.start_server(all_app) + dashr.find_element("#add-filter").click() + dashr.select_dcc_dropdown('#\\{\\"bar\\"\\:1\\,\\"foo\\"\\:\\"filter-dropdown\\"\\}', "NYC") + dashr.wait_for_text_to_equal( + "#dropdown-container-output", + "Dropdown 1 = NYC" + ) + dashr.find_element("#add-filter").click() + dashr.select_dcc_dropdown('#\\{\\"bar\\"\\:2\\,\\"foo\\"\\:\\"filter-dropdown\\"\\}', "MTL") + dashr.wait_for_text_to_equal( + "#dropdown-container-output", + "Dropdown 1 = NYC" + "\n" + "Dropdown 2 = MTL" + ) + + +def test_rpmc002_pattern_matching_allsmaller(dashr): + dashr.start_server(allsmaller_app) + dashr.find_element("#add-filter-ex3").click() + dashr.select_dcc_dropdown('#\\{\\"index\\"\\:1\\,\\"type\\"\\:\\"filter-dropdown-ex3\\"\\}', "Argentina") + dashr.find_element("#add-filter-ex3").click() + dashr.select_dcc_dropdown('#\\{\\"index\\"\\:2\\,\\"type\\"\\:\\"filter-dropdown-ex3\\"\\}', "Angola") + dashr.wait_for_text_to_equal( + "#test", + "59.03 is the life expectancy of Angola and Argentina." + ) + + +def test_rpmc003_pattern_matching_match(dashr): + dashr.start_server(match_app) + dashr.find_element("#dynamic-add-filter").click() + dashr.select_dcc_dropdown('#\\{\\"index\\"\\:1\\,\\"type\\"\\:\\"dynamic-dropdown\\"\\}', "NYC") + dashr.wait_for_text_to_equal( + '#\\{\\"index\\"\\:1\\,\\"type\\"\\:\\"dynamic-output\\"\\}', + "Dropdown 1 = NYC" + ) + dashr.find_element("#dynamic-add-filter").click() + dashr.select_dcc_dropdown('#\\{\\"index\\"\\:2\\,\\"type\\"\\:\\"dynamic-dropdown\\"\\}', "MTL") + dashr.wait_for_text_to_equal( + '#\\{\\"index\\"\\:2\\,\\"type\\"\\:\\"dynamic-output\\"\\}', + "Dropdown 2 = MTL" + ) + + +def test_rpmc004_pattern_matching_todo(dashr): + dashr.start_server(todo_app) + dashr.find_element("#new-item").send_keys("Item 1") + dashr.find_element("#add").click() + dashr.find_element("#new-item").send_keys("Item 2") + dashr.find_element("#add").click() + dashr.find_element("#new-item").send_keys("Item 3") + dashr.find_element("#add").click() + dashr.find_element('#\\{\\"index\\"\\:1\\,\\"type\\"\\:\\"done\\"\\}').click() + dashr.find_element("#clear-done").click() + dashr.wait_for_text_to_equal("#totals", "0 of 2 items completed - 0%") diff --git a/tests/testthat/test-wildcards.R b/tests/testthat/test-wildcards.R index 6d1be1ec..3e7f1daf 100644 --- a/tests/testthat/test-wildcards.R +++ b/tests/testthat/test-wildcards.R @@ -24,5 +24,6 @@ test_that("HTML `data-*` & `aria-* ` wildcards are passed along to layout approp expect_equal(x$props$`data-foo`, 1) }) + # TODO: test NULL values aren't rendered on the HTML div # https://github.com/plotly/dash/pull/237/files#r179251041