From a44050a27ba55056afed8ebac81e852ddf852e1f Mon Sep 17 00:00:00 2001 From: Ryan Patrick Kyle Date: Mon, 12 Aug 2019 23:20:18 -0400 Subject: [PATCH 01/24] Provide support for no_update in Dash for R (#111) --- NAMESPACE | 1 + R/dash.R | 10 +- R/dependencies.R | 12 +- man/dependencies.Rd | 7 ++ tests/integration/callbacks/test_no_update.py | 111 ++++++++++++++++++ 5 files changed, 138 insertions(+), 3 deletions(-) create mode 100644 tests/integration/callbacks/test_no_update.py diff --git a/NAMESPACE b/NAMESPACE index c18633f2..e7d4dc17 100644 --- a/NAMESPACE +++ b/NAMESPACE @@ -2,6 +2,7 @@ S3method(print,dash_component) export(Dash) +export(dashNoUpdate) export(input) export(output) export(state) diff --git a/R/dash.R b/R/dash.R index e073b982..f5750f22 100644 --- a/R/dash.R +++ b/R/dash.R @@ -287,11 +287,17 @@ Dash <- R6::R6Class( output_value <- getStackTrace(do.call(callback, callback_args), debug = private$debug, pruned_errors = private$pruned_errors) - + # reset callback context private$callback_context_ <- NULL - if (is.null(private$stack_message)) { + # inspect the output_value to determine whether any outputs have no_update + # objects within them; these should not be updated + if (length(output_value) == 1 && class(output_value) == "no_update") { + response$body <- character(1) # return empty string + response$status <- 204L + } + else if (is.null(private$stack_message)) { # pass on output_value to encode_plotly in case there are dccGraph # components which include Plotly.js figures for which we'll need to # run plotly_build from the plotly package diff --git a/R/dependencies.R b/R/dependencies.R index f063c5d1..77ec8b77 100644 --- a/R/dependencies.R +++ b/R/dependencies.R @@ -5,11 +5,13 @@ #' Use in conjunction with the `callback()` method from the [dash::Dash] class #' to define the update logic in your application. #' +#' The `dashNoUpdate()` function permits application developers to prevent a +#' single output from updating the layout. It has no formal arguments. +#' #' @name dependencies #' @param id a component id #' @param property the component property to use - #' @rdname dependencies #' @export output <- function(id, property) { @@ -44,3 +46,11 @@ dependency <- function(id = NULL, property = NULL) { property = property ) } + +#' @rdname dependencies +#' @export +dashNoUpdate <- function() { + x <- list(NULL) + class(x) <- "no_update" + return(x) +} diff --git a/man/dependencies.Rd b/man/dependencies.Rd index b7dea530..c303bc1f 100644 --- a/man/dependencies.Rd +++ b/man/dependencies.Rd @@ -5,6 +5,7 @@ \alias{output} \alias{input} \alias{state} +\alias{dashNoUpdate} \title{Input/Output/State definitions} \usage{ output(id, property) @@ -12,6 +13,8 @@ output(id, property) input(id, property) state(id, property) + +dashNoUpdate() } \arguments{ \item{id}{a component id} @@ -22,3 +25,7 @@ state(id, property) Use in conjunction with the \code{callback()} method from the \link[dash:Dash]{dash::Dash} class 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. +} diff --git a/tests/integration/callbacks/test_no_update.py b/tests/integration/callbacks/test_no_update.py new file mode 100644 index 00000000..f8de0fe6 --- /dev/null +++ b/tests/integration/callbacks/test_no_update.py @@ -0,0 +1,111 @@ +from selenium.webdriver.support.select import Select +import time + +app = """ +library(dash) +library(dashHtmlComponents) +library(dashCoreComponents) + +app <- Dash$new() + +app$layout( + htmlDiv(list( + dccDropdown(options = list( + list(label = "Red", value = "#FF0000"), + list(label = "Green", value = "#00FF00"), + list(label = "Blue", value = "#0000FF"), + list(label = "Do nothing", value = "nothing") + ), + id = "color-selector"), + htmlButton(children = "Select all the colors!", + id = "multi-selector" + ), + htmlDiv(id='message-box', + children='Please select a color choice from the dropdown menu.'), + htmlDiv(id='message-box2', + children=' ') + ) + ) +) + +app$callback(output=list(id='message-box2', property='children'), + params=list( + input(id='multi-selector', property='n_clicks')), + function(n_clicks) + { + # if button has been clicked, n_clicks is numeric() + # on first launch of callback at layout initialization, + # value of n_clicks will be list(NULL), which is not + # comparable using >, < or =; hence the is.numeric() + # check + if (is.numeric(n_clicks) && n_clicks >= 1) + { + # return a vector to ensure that the check for + # class(x) == "no_update" isn't made for objects + # where length(x) > 1 + return(c("Multiple color values: ", + "#FF0000, ", + "#00FF00, ", + "#0000FF ", + "returned!") + ) + } + } +) + +app$callback(output=list(id='message-box', property='children'), + params=list( + input(id='color-selector', property='value')), + function(color) + { + if (color %in% c("#FF0000", "#00FF00", "#0000FF")) { + msg <- sprintf("The hexadecimal representation of your last chosen color is %s", + color) + return(msg) + } else { + return(dashNoUpdate()) + } + } +) + +app$run_server() +""" + + +def test_rsnu001_no_update(dashr): + dashr.start_server(app) + dashr.find_element("#color-selector").click() + dashr.find_elements("div.VirtualizedSelectOption")[0].click() + dashr.wait_for_text_to_equal( + "#message-box", + "The hexadecimal representation of your last chosen color is #FF0000" + ) + dashr.find_element("#color-selector").click() + dashr.find_elements("div.VirtualizedSelectOption")[3].click() + time.sleep(1) + assert dashr.find_element("#message-box").text == "The hexadecimal representation of your last chosen color is #FF0000" + dashr.find_element("#color-selector").click() + dashr.find_elements("div.VirtualizedSelectOption")[1].click() + dashr.wait_for_text_to_equal( + "#message-box", + "The hexadecimal representation of your last chosen color is #00FF00" + ) + dashr.find_element("#color-selector").click() + dashr.find_elements("div.VirtualizedSelectOption")[3].click() + time.sleep(1) + assert dashr.find_element("#message-box").text == "The hexadecimal representation of your last chosen color is #00FF00" + dashr.find_element("#color-selector").click() + dashr.find_elements("div.VirtualizedSelectOption")[2].click() + dashr.wait_for_text_to_equal( + "#message-box", + "The hexadecimal representation of your last chosen color is #0000FF" + ) + dashr.find_element("#color-selector").click() + dashr.find_elements("div.VirtualizedSelectOption")[3].click() + time.sleep(1) + assert dashr.find_element("#message-box").text == "The hexadecimal representation of your last chosen color is #0000FF" + dashr.find_element("#multi-selector").click() + dashr.wait_for_text_to_equal( + "#message-box2", + "Multiple color values: #FF0000, #00FF00, #0000FF returned!" + ) From 2990c725d81c40c3ce3bb08b7984dcb4cf1fce2f Mon Sep 17 00:00:00 2001 From: Ryan Patrick Kyle Date: Sat, 17 Aug 2019 09:45:33 -0400 Subject: [PATCH 02/24] :sparkles: add createCallbackId --- R/utils.R | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/R/utils.R b/R/utils.R index d8d67664..911394bc 100644 --- a/R/utils.R +++ b/R/utils.R @@ -828,3 +828,17 @@ getDashMetadata <- function(pkgname) { metadataFn <- as.vector(fnList[grepl("^\\.dash.+_js_metadata$", fnList)]) return(metadataFn) } + +createCallbackId <- function(output) { + # check if callback uses single output + if (!any(sapply(output, is.list))) { + id <- paste0(output, collapse=".") + } else { + # multi-output callback, concatenate + ids <- vapply(output, function(x) { + paste(x, collapse = ".") + }, character(1)) + id <- paste0("..", paste0(ids, collapse="..."), "..") + } + return(id) +} From d476c91725b1e21b2c4d2a85cd6ceac4714eebd5 Mon Sep 17 00:00:00 2001 From: Ryan Patrick Kyle Date: Wed, 21 Aug 2019 18:43:31 -0400 Subject: [PATCH 03/24] :sparkles: add support for multiple outputs --- R/dash.R | 40 +++++++++++++++++++++++++++++++++------- R/utils.R | 16 ++++++++++++++-- 2 files changed, 47 insertions(+), 9 deletions(-) diff --git a/R/dash.R b/R/dash.R index f5750f22..e82b91e9 100644 --- a/R/dash.R +++ b/R/dash.R @@ -222,7 +222,7 @@ Dash <- R6::R6Class( payload <- Map(function(callback_signature) { list( inputs=callback_signature$inputs, - output=paste0(callback_signature$output, collapse="."), + output=createCallbackId(callback_signature$output), state=callback_signature$state ) }, private$callback_map) @@ -305,12 +305,38 @@ Dash <- R6::R6Class( # have to format the response body like this # https://github.com/plotly/dash/blob/064c811d/dash/dash.py#L562-L584 - resp <- list( - response = list( - props = setNames(list(output_value), gsub( "(^.+)(\\.)", "", request$body$output)) + if (grepl("\\.\\.\\.", request$body$output)) { + # if multi-output callback, isolate the output IDs and properties + idmatch <- gregexpr("(?<=\\.\\.)(([^\\.]+))(?=\\.)", request$body$output, perl=TRUE) + propmatch <- gregexpr("(?<=\\.)(([^\\.]+))(?=\\.\\.)", request$body$output, perl=TRUE) + ids <- unlist(regmatches(request$body$output, idmatch)) + props <- unlist(regmatches(request$body$output, propmatch)) + + # prepare a response object which has list elements corresponding to ids + # which themselves contain named list elements corresponding to props + # then fill in nested list elements based on output_value + + allprops <- setNames(vector("list", length(unique(ids))), unique(ids)) + + idmap <- setNames(ids, props) + + for (id in unique(ids)) { + allprops[[id]] <- output_value[grep(id, ids)] + names(allprops[[id]]) <- names(idmap[which(idmap==id)]) + } + + resp <- list( + response = allprops, + multi = TRUE + ) + } else { + resp <- list( + response = list( + props = setNames(list(output_value), gsub( "(^.+)(\\.)", "", request$body$output)) + ) ) - ) - + } + response$body <- to_JSON(resp) response$status <- 200L response$type <- 'json' @@ -506,7 +532,7 @@ Dash <- R6::R6Class( state <- params[vapply(params, function(x) 'state' %in% attr(x, "class"), FUN.VALUE=logical(1))] # register the callback_map - private$callback_map[[paste(output$id, output$property, sep='.')]] <- list( + private$callback_map[[createCallbackId(output)]] <- list( inputs=inputs, output=output, state=state, diff --git a/R/utils.R b/R/utils.R index 911394bc..a8f71271 100644 --- a/R/utils.R +++ b/R/utils.R @@ -371,10 +371,22 @@ assert_valid_callbacks <- function(output, params, func) { } # Assert that the component ID as passed is a string. - if(!(is.character(output$id) & !grepl("^\\s*$", output$id) & !grepl("\\.", output$id))) { - 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) + # This function inspects the output object to see if its ID + # is a valid string. + validateOutput <- function(string) { + return((is.character(string[["id"]]) & !grepl("^\\s*$", string[["id"]]) & !grepl("\\.", string[["id"]]))) } + # Check if the callback uses multiple outputs + if (any(sapply(output, is.list))) { + invalid_callback_ID <- (!all(vapply(output, validateOutput, logical(1)))) + } else { + invalid_callback_ID <- (!validateOutput(output)) + } + if (invalid_callback_ID) { + 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) + } + # 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) From b274c58160869b93f119bcddbf9137aad55e036b Mon Sep 17 00:00:00 2001 From: Ryan Patrick Kyle Date: Fri, 23 Aug 2019 01:53:15 -0400 Subject: [PATCH 04/24] :rotating_light: add checks for multi-output callbacks --- R/utils.R | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/R/utils.R b/R/utils.R index a8f71271..2cc5ebf3 100644 --- a/R/utils.R +++ b/R/utils.R @@ -409,7 +409,22 @@ assert_valid_callbacks <- function(output, params, func) { # Check that outputs are not inputs # https://github.com/plotly/dash/issues/323 - inputs_vs_outputs <- lapply(inputs, function(x) identical(x, output)) + + # helper function to permit same mapply syntax regardless + # of whether output is defined using output function or not + listWrap <- function(x){ + if (!any(sapply(x, is.list))) { + return(list(x)) + } else { + x + } + } + + # determine whether any input matches the output, or outputs, if + # multiple callback scenario + inputs_vs_outputs <- mapply(function(inputObject, outputObject) { + identical(outputObject[["id"]], inputObject[["id"]]) & identical(outputObject[["property"]], inputObject[["property"]]) + }, inputs, listWrap(output)) if(TRUE %in% inputs_vs_outputs) { stop(sprintf("Circular input and output arguments were found. Please verify that callback outputs are not also input arguments."), call. = FALSE) From d77a0cec47c7e47e9ced64640295b066d76d24ff Mon Sep 17 00:00:00 2001 From: Ryan Patrick Kyle Date: Fri, 23 Aug 2019 01:53:50 -0400 Subject: [PATCH 05/24] :rotating_light: add test for multiple outputs --- .../integration/callbacks/multiple_outputs.py | 88 +++++++++++++++++++ 1 file changed, 88 insertions(+) create mode 100644 tests/integration/callbacks/multiple_outputs.py diff --git a/tests/integration/callbacks/multiple_outputs.py b/tests/integration/callbacks/multiple_outputs.py new file mode 100644 index 00000000..d2e5ce7f --- /dev/null +++ b/tests/integration/callbacks/multiple_outputs.py @@ -0,0 +1,88 @@ +from selenium.webdriver.support.select import Select +import time + +app = """ +library(dash) +library(dashHtmlComponents) +library(dashCoreComponents) +library(plotly) +library(dashTable) + +app <- Dash$new() + +app$layout( + htmlDiv(list( + htmlDiv(list( + htmlH1('Multi output example'), + dccDropdown(id='data-dropdown', + options = list( + list(label = 'Movies', + value = 'movies'), + list(label = 'Series', + value = 'series') + ), + value = 'movies') + ), + id = 'container', + style = list( + backgroundColor = '#ff998a' + ) + ), + htmlDiv(list( + htmlH2('Make a selection from the dropdown menu.', + id = 'text-box') + ) + ) + ) + ) +) + +app$callback(output=list( + output(id='text-box', property='children'), + output(id='container', property='style') +), +params=list( + input(id='data-dropdown', property='value') +), +function(value) { + if (is.null(value)) { + return(dashNoUpdate()) + } + + if (value == "series") { + style <- list( + backgroundColor = '#ff998a' + ) + } else { + style <- list( + backgroundColor = '#fff289' + ) + } + + return(list(sprintf("You have chosen %s.", value), + style)) +} +) + +app$run_server(debug=TRUE) +""" + + +def test_rsnu001_multiple_outputs(dashr): + dashr.start_server(app) + dashr.find_element("#data-dropdown").click() + dashr.find_elements("div.VirtualizedSelectOption")[1].click() + dashr.wait_for_text_to_equal( + "#text-box", + "You have chosen series." + ) + backgroundColor = dashr.find_element('#container').value_of_css_property("background-color") + assert backgroundColor == "rgba(255, 153, 138, 1)" + dashr.find_element("#data-dropdown").click() + dashr.find_elements("div.VirtualizedSelectOption")[0].click() + dashr.wait_for_text_to_equal( + "#text-box", + "You have chosen movies." + ) + backgroundColor = dashr.find_element('#container').value_of_css_property("background-color") + assert backgroundColor == "rgba(255, 242, 137, 1)" From d26c4a2d4620400b4fd65c119915b01c1942bf7d Mon Sep 17 00:00:00 2001 From: Ryan Patrick Kyle Date: Mon, 26 Aug 2019 10:20:37 -0400 Subject: [PATCH 06/24] :hammer: replace regex with strsplit --- R/dash.R | 7 +++---- R/utils.R | 8 ++++++++ 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/R/dash.R b/R/dash.R index e82b91e9..58decf78 100644 --- a/R/dash.R +++ b/R/dash.R @@ -307,10 +307,9 @@ Dash <- R6::R6Class( # https://github.com/plotly/dash/blob/064c811d/dash/dash.py#L562-L584 if (grepl("\\.\\.\\.", request$body$output)) { # if multi-output callback, isolate the output IDs and properties - idmatch <- gregexpr("(?<=\\.\\.)(([^\\.]+))(?=\\.)", request$body$output, perl=TRUE) - propmatch <- gregexpr("(?<=\\.)(([^\\.]+))(?=\\.\\.)", request$body$output, perl=TRUE) - ids <- unlist(regmatches(request$body$output, idmatch)) - props <- unlist(regmatches(request$body$output, propmatch)) + + ids <- getIdProps(request$body$output)$ids + props <- getIdProps(request$body$output)$props # prepare a response object which has list elements corresponding to ids # which themselves contain named list elements corresponding to props diff --git a/R/utils.R b/R/utils.R index 2cc5ebf3..fb0bf666 100644 --- a/R/utils.R +++ b/R/utils.R @@ -869,3 +869,11 @@ createCallbackId <- function(output) { } return(id) } + +getIdProps <- function(output) { + output_ids <- strsplit(substr(output, 3, nchar(output)-2), '...', fixed=TRUE) + idprops <- lapply(idlist, strsplit, '.', fixed=TRUE) + ids <- vapply(unlist(idprops, recursive=FALSE), '[', character(1), 1) + props <- vapply(unlist(idprops, recursive=FALSE), '[', character(1), 2) + return(list(ids=ids, props=props)) +} From 981ca3b403788011ad62ff2ec85096b4f44abe46 Mon Sep 17 00:00:00 2001 From: Ryan Patrick Kyle Date: Mon, 26 Aug 2019 10:30:45 -0400 Subject: [PATCH 07/24] :bug: rename idlist to output_ids --- R/dash.R | 3 +-- R/utils.R | 2 +- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/R/dash.R b/R/dash.R index 58decf78..898b8b2d 100644 --- a/R/dash.R +++ b/R/dash.R @@ -305,9 +305,8 @@ Dash <- R6::R6Class( # have to format the response body like this # https://github.com/plotly/dash/blob/064c811d/dash/dash.py#L562-L584 - if (grepl("\\.\\.\\.", request$body$output)) { + if (substr(request$body$output, 1, 2) == '..') { # if multi-output callback, isolate the output IDs and properties - ids <- getIdProps(request$body$output)$ids props <- getIdProps(request$body$output)$props diff --git a/R/utils.R b/R/utils.R index fb0bf666..257c835c 100644 --- a/R/utils.R +++ b/R/utils.R @@ -872,7 +872,7 @@ createCallbackId <- function(output) { getIdProps <- function(output) { output_ids <- strsplit(substr(output, 3, nchar(output)-2), '...', fixed=TRUE) - idprops <- lapply(idlist, strsplit, '.', fixed=TRUE) + idprops <- lapply(output_ids, strsplit, '.', fixed=TRUE) ids <- vapply(unlist(idprops, recursive=FALSE), '[', character(1), 1) props <- vapply(unlist(idprops, recursive=FALSE), '[', character(1), 2) return(list(ids=ids, props=props)) From 10a04f407d90880648c476a48f5e9f385f7b2ad5 Mon Sep 17 00:00:00 2001 From: Ryan Patrick Kyle Date: Mon, 26 Aug 2019 10:37:23 -0400 Subject: [PATCH 08/24] :pencil2: updated comments --- R/dash.R | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/R/dash.R b/R/dash.R index 898b8b2d..5aceed7b 100644 --- a/R/dash.R +++ b/R/dash.R @@ -303,8 +303,11 @@ Dash <- R6::R6Class( # run plotly_build from the plotly package output_value <- encode_plotly(output_value) - # have to format the response body like this - # https://github.com/plotly/dash/blob/064c811d/dash/dash.py#L562-L584 + # for multiple outputs, have to format the response body like this, including 'multi' key: + # https://github.com/plotly/dash/blob/d9ddc877d6b15d9354bcef4141acca5d5fe6c07b/dash/dash.py#L1174-L1209 + + # for single outputs, the response body is formatted slightly differently: + # https://github.com/plotly/dash/blob/d9ddc877d6b15d9354bcef4141acca5d5fe6c07b/dash/dash.py#L1210-L1220 if (substr(request$body$output, 1, 2) == '..') { # if multi-output callback, isolate the output IDs and properties ids <- getIdProps(request$body$output)$ids From 16fee5ee5a087376efaf169dde93479aa950f9f6 Mon Sep 17 00:00:00 2001 From: Ryan Patrick Kyle Date: Mon, 26 Aug 2019 22:40:43 -0400 Subject: [PATCH 09/24] :hammer: support partial updates --- NAMESPACE | 1 + R/dash.R | 13 ++++++++----- 2 files changed, 9 insertions(+), 5 deletions(-) diff --git a/NAMESPACE b/NAMESPACE index e7d4dc17..f600e6b9 100644 --- a/NAMESPACE +++ b/NAMESPACE @@ -3,6 +3,7 @@ S3method(print,dash_component) export(Dash) export(dashNoUpdate) +export(createCallbackId) export(input) export(output) export(state) diff --git a/R/dash.R b/R/dash.R index 5aceed7b..55e3d3d7 100644 --- a/R/dash.R +++ b/R/dash.R @@ -196,7 +196,6 @@ Dash <- R6::R6Class( dash_layout <- paste0(self$config$routes_pathname_prefix, "_dash-layout") route$add_handler("get", dash_layout, function(request, response, keys, ...) { - rendered_layout <- private$layout_render() # pass the layout on to encode_plotly in case there are dccGraph # components which include Plotly.js figures for which we'll need to @@ -210,7 +209,6 @@ Dash <- R6::R6Class( dash_deps <- paste0(self$config$routes_pathname_prefix, "_dash-dependencies") route$add_handler("get", dash_deps, function(request, response, keys, ...) { - # dash-renderer wants an empty array when no dependencies exist (see python/01.py) if (!length(private$callback_map)) { response$body <- to_JSON(list()) @@ -290,7 +288,7 @@ Dash <- R6::R6Class( # reset callback context private$callback_context_ <- NULL - + # inspect the output_value to determine whether any outputs have no_update # objects within them; these should not be updated if (length(output_value) == 1 && class(output_value) == "no_update") { @@ -308,10 +306,15 @@ Dash <- R6::R6Class( # for single outputs, the response body is formatted slightly differently: # https://github.com/plotly/dash/blob/d9ddc877d6b15d9354bcef4141acca5d5fe6c07b/dash/dash.py#L1210-L1220 + if (substr(request$body$output, 1, 2) == '..') { + # omit return objects of class "no_update" from output_value + updatable_outputs <- "no_update" != vapply(output_value, class, character(1)) + output_value <- output_value[updatable_outputs] + # if multi-output callback, isolate the output IDs and properties - ids <- getIdProps(request$body$output)$ids - props <- getIdProps(request$body$output)$props + ids <- getIdProps(request$body$output)$ids[updatable_outputs] + props <- getIdProps(request$body$output)$props[updatable_outputs] # prepare a response object which has list elements corresponding to ids # which themselves contain named list elements corresponding to props From 83d35010e569c1af9cd476efbb4ca74b0ec23da3 Mon Sep 17 00:00:00 2001 From: Ryan Patrick Kyle Date: Tue, 27 Aug 2019 01:18:35 -0400 Subject: [PATCH 10/24] :rotating_light: add test for partial outputs --- .../integration/callbacks/multiple_outputs.py | 50 ++++++++++++++----- 1 file changed, 37 insertions(+), 13 deletions(-) diff --git a/tests/integration/callbacks/multiple_outputs.py b/tests/integration/callbacks/multiple_outputs.py index d2e5ce7f..55c4c156 100644 --- a/tests/integration/callbacks/multiple_outputs.py +++ b/tests/integration/callbacks/multiple_outputs.py @@ -1,5 +1,4 @@ from selenium.webdriver.support.select import Select -import time app = """ library(dash) @@ -9,7 +8,6 @@ library(dashTable) app <- Dash$new() - app$layout( htmlDiv(list( htmlDiv(list( @@ -20,50 +18,60 @@ value = 'movies'), list(label = 'Series', value = 'series') - ), + ), value = 'movies') ), id = 'container', style = list( backgroundColor = '#ff998a' - ) + ) ), htmlDiv(list( htmlH2('Make a selection from the dropdown menu.', - id = 'text-box') - ) - ) + id = 'text-box'), + dccRadioItems(id='radio-partial', + options = list( + list(label = 'All', + value = 'all'), + list(label = 'Do not update colour', + value = 'static') + ), + value = 'all') ) ) + ) + ) ) - app$callback(output=list( output(id='text-box', property='children'), output(id='container', property='style') ), params=list( - input(id='data-dropdown', property='value') + input(id='data-dropdown', property='value'), + input(id='radio-partial', property='value') ), -function(value) { +function(value, choice) { if (is.null(value)) { return(dashNoUpdate()) } - if (value == "series") { + if (choice == "all" && value == "series") { style <- list( backgroundColor = '#ff998a' ) - } else { + } else if (choice == "all") { style <- list( backgroundColor = '#fff289' ) + } else { + return(list(sprintf("You have chosen %s.", value), + dashNoUpdate())) } return(list(sprintf("You have chosen %s.", value), style)) } ) - app$run_server(debug=TRUE) """ @@ -86,3 +94,19 @@ def test_rsnu001_multiple_outputs(dashr): ) backgroundColor = dashr.find_element('#container').value_of_css_property("background-color") assert backgroundColor == "rgba(255, 242, 137, 1)" + dashr.find_elements("input[type='radio']")[1].click() + dashr.find_element("#data-dropdown").click() + dashr.find_elements("div.VirtualizedSelectOption")[1].click() + dashr.wait_for_text_to_equal( + "#text-box", + "You have chosen series." + ) + assert backgroundColor == "rgba(255, 242, 137, 1)" + dashr.find_elements("input[type='radio']")[0].click() + dashr.find_element("#data-dropdown").click() + dashr.find_elements("div.VirtualizedSelectOption")[0].click() + dashr.wait_for_text_to_equal( + "#text-box", + "You have chosen movies." + ) + assert backgroundColor == "rgba(255, 242, 137, 1)" From f1f472427172fdf4e100ef9560133922cd2601d7 Mon Sep 17 00:00:00 2001 From: Ryan Patrick Kyle Date: Tue, 27 Aug 2019 07:35:11 -0400 Subject: [PATCH 11/24] :rotating_light: add check for duplicated outputs --- R/utils.R | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/R/utils.R b/R/utils.R index 257c835c..71335949 100644 --- a/R/utils.R +++ b/R/utils.R @@ -138,7 +138,7 @@ render_dependencies <- function(dependencies, local = TRUE, prefix=NULL) { # According to Dash convention, label react and react-dom as originating # in dash_renderer package, even though all three are currently served - # u p from the DashR package + # up from the DashR package if (dep$name %in% c("react", "react-dom", "prop-types")) { dep$name <- "dash-renderer" } @@ -355,11 +355,16 @@ clean_dependencies <- function(deps) { assert_valid_callbacks <- function(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))] - + invalid_params <- vapply(params, function(x) { !any(c('input', 'state') %in% attr(x, "class")) }, FUN.VALUE=logical(1)) + # Verify that no outputs are duplicated + if (length(output) != length(unique(output))) { + stop(sprintf("One or more callback outputs have been duplicated; please confirm that all outputs are unique."), call. = FALSE) + } + # Verify that params contains no elements that are not either members of 'input' or 'state' classes if (any(invalid_params)) { stop(sprintf("Callback parameters must be inputs or states. Please verify formatting of callback parameters."), call. = FALSE) From 508eeb1bed50fa74553d679a625fb534a3a80b9b Mon Sep 17 00:00:00 2001 From: Ryan Patrick Kyle Date: Thu, 29 Aug 2019 00:39:33 -0400 Subject: [PATCH 12/24] :sparkles: add insertIntoCallbackMap --- R/dash.R | 14 +++++++------- R/utils.R | 19 +++++++++++++++++++ 2 files changed, 26 insertions(+), 7 deletions(-) diff --git a/R/dash.R b/R/dash.R index 55e3d3d7..e4c49765 100644 --- a/R/dash.R +++ b/R/dash.R @@ -534,14 +534,14 @@ 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))] - + # register the callback_map - private$callback_map[[createCallbackId(output)]] <- list( - inputs=inputs, - output=output, - state=state, - func=func - ) + private$callback_map <- insertIntoCallbackMap(private$callback_map, + inputs, + output, + state, + func) + }, # ------------------------------------------------------------------------ diff --git a/R/utils.R b/R/utils.R index 71335949..d656fac3 100644 --- a/R/utils.R +++ b/R/utils.R @@ -352,6 +352,25 @@ clean_dependencies <- function(deps) { return(deps_with_file) } +insertIntoCallbackMap <- function(map, inputs, output, state, func) { + map[[createCallbackId(output)]] <- list(inputs=inputs, + output=output, + state=state, + func=func + ) + if (length(map) >= 2) { + ids <- lapply(names(map), function(x) dash:::getIdProps(x)$ids) + props <- lapply(names(map), function(x) dash:::getIdProps(x)$props) + + outputs_as_list <- mapply(paste, ids, props, sep=".") + + 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) + } + } + return(map) +} + assert_valid_callbacks <- function(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))] From df19ffe931cae4c71e857cfb30e194a598f984a3 Mon Sep 17 00:00:00 2001 From: Ryan Patrick Kyle Date: Thu, 29 Aug 2019 08:13:16 -0400 Subject: [PATCH 13/24] modify R :package: install process --- .circleci/config.yml | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/.circleci/config.yml b/.circleci/config.yml index fa6e5889..b34e1f79 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -26,10 +26,7 @@ jobs: - run: name: 🚧 install R dependencies command: | - sudo Rscript -e 'install.packages("remotes")' - sudo R -e "remotes::install_github('plotly/dash-core-components', dependencies=TRUE)" - sudo R -e "remotes::install_github('plotly/dash-html-components', dependencies=TRUE)" - sudo R -e "remotes::install_github('plotly/dash-table', dependencies=TRUE)" + sudo Rscript -e 'install.packages("remotes"); remotes::install_github('plotly/dashR', dependencies=TRUE, upgrade=TRUE)' sudo R CMD INSTALL . - run: From b08eab6c7fc5d1b674bd9c8fa003da3beeda9f34 Mon Sep 17 00:00:00 2001 From: Ryan Patrick Kyle Date: Thu, 29 Aug 2019 09:23:36 -0400 Subject: [PATCH 14/24] :see_no_evil: fix quotation marks --- .circleci/config.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.circleci/config.yml b/.circleci/config.yml index b34e1f79..1798f758 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -26,7 +26,7 @@ jobs: - run: name: 🚧 install R dependencies command: | - sudo Rscript -e 'install.packages("remotes"); remotes::install_github('plotly/dashR', dependencies=TRUE, upgrade=TRUE)' + sudo Rscript -e 'install.packages("remotes"); remotes::install_github("plotly/dashR", dependencies=TRUE, upgrade=TRUE)' sudo R CMD INSTALL . - run: From 30ba139497c18c89c3d60f432a72bb1174127145 Mon Sep 17 00:00:00 2001 From: Ryan Patrick Kyle Date: Thu, 29 Aug 2019 09:28:15 -0400 Subject: [PATCH 15/24] condense further, run R once to install :package: --- .circleci/config.yml | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/.circleci/config.yml b/.circleci/config.yml index 1798f758..2a7d6702 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -26,8 +26,7 @@ jobs: - run: name: 🚧 install R dependencies command: | - sudo Rscript -e 'install.packages("remotes"); remotes::install_github("plotly/dashR", dependencies=TRUE, upgrade=TRUE)' - sudo R CMD INSTALL . + sudo Rscript -e 'install.packages("remotes"); remotes::install_github("plotly/dashR", dependencies=TRUE, upgrade=TRUE); install.packages(".", type="source", repos=NULL)' - run: name: ⚙️ run integration test From 5521a930107f78638b7b9673a30ffa1a0be87d8d Mon Sep 17 00:00:00 2001 From: Ryan Patrick Kyle Date: Fri, 30 Aug 2019 00:46:39 -0400 Subject: [PATCH 16/24] :rotating_light: for output=list, output=output --- tests/testthat.R | 4 ++++ tests/testthat/test-callback.R | 38 ++++++++++++++++++++++++++++++++++ 2 files changed, 42 insertions(+) create mode 100644 tests/testthat.R create mode 100644 tests/testthat/test-callback.R diff --git a/tests/testthat.R b/tests/testthat.R new file mode 100644 index 00000000..ce72bfcd --- /dev/null +++ b/tests/testthat.R @@ -0,0 +1,4 @@ +library(testthat) +library(dash) + +test_check("dash") diff --git a/tests/testthat/test-callback.R b/tests/testthat/test-callback.R new file mode 100644 index 00000000..2531b9c1 --- /dev/null +++ b/tests/testthat/test-callback.R @@ -0,0 +1,38 @@ +context("callback") + +test_that("Callback outputs can be provided with or without output function", { + + app <- Dash$new() + + app$layout( + htmlDiv( + list( + dccInput(id='input-1-state', type='text', value='Montreal'), + dccInput(id='input-2-state', type='text', value='Canada'), + htmlButton(id='submit-button', n_clicks=0, children='Submit'), + htmlDiv(id='output-state') + ) + ) + ) + + expect_silent( + app$callback(output(id = 'output-state', property = 'children'), + list(input(id = 'submit-button', property = 'n_clicks'), + state(id = 'input-1-state', property = 'value'), + state(id = 'input-2-state', property = 'value')), + function(n_clicks, input1, input2) { + sprintf("The Button has been pressed \"%s\" times, Input 1 is \"%s\", and Input 2 is \"%s\"", n_clicks, input1, input2) + }) + ) + + expect_silent( + app$callback(output=list(id = 'output-state', property = 'children'), + list(input(id = 'submit-button', property = 'n_clicks'), + state(id = 'input-1-state', property = 'value'), + state(id = 'input-2-state', property = 'value')), + function(n_clicks, input1, input2) { + sprintf("The Button has been pressed \"%s\" times, Input 1 is \"%s\", and Input 2 is \"%s\"", n_clicks, input1, input2) + }) + ) +}) + From 000fcbb74c3b985dadc80621c338367409bdd2cf Mon Sep 17 00:00:00 2001 From: Ryan Patrick Kyle Date: Fri, 30 Aug 2019 07:45:21 -0400 Subject: [PATCH 17/24] :hammer: fix missing argument --- R/utils.R | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/R/utils.R b/R/utils.R index d656fac3..2b982d4d 100644 --- a/R/utils.R +++ b/R/utils.R @@ -362,7 +362,7 @@ insertIntoCallbackMap <- function(map, inputs, output, state, func) { ids <- lapply(names(map), function(x) dash:::getIdProps(x)$ids) props <- lapply(names(map), function(x) dash:::getIdProps(x)$props) - outputs_as_list <- mapply(paste, ids, props, sep=".") + 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) From 64dcfe3840edd0f5967753e506954d2e0e840b96 Mon Sep 17 00:00:00 2001 From: Ryan Patrick Kyle Date: Fri, 30 Aug 2019 07:47:32 -0400 Subject: [PATCH 18/24] :rotating_light: add test for repeated outputs --- tests/testthat/test-callback.R | 40 ++++++++++++++++++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/tests/testthat/test-callback.R b/tests/testthat/test-callback.R index 2531b9c1..f3ff62e1 100644 --- a/tests/testthat/test-callback.R +++ b/tests/testthat/test-callback.R @@ -36,3 +36,43 @@ test_that("Callback outputs can be provided with or without output function", { ) }) +test_that("Repeating outputs across callbacks yields an error", { + + app <- Dash$new() + + app$layout( + htmlDiv( + list( + dccInput(id='input-1-state', type='text', value='Montreal'), + dccInput(id='input-2-state', type='text', value='Canada'), + htmlButton(id='submit-button', n_clicks=0, children='Submit'), + dccInput(id='input-3-state', type='text', value='Quebec'), + dccInput(id='input-4-state', type='text', value='Canada'), + htmlButton(id='submit-button2', n_clicks=0, children='Submit'), + htmlDiv(id='output-state'), + htmlDiv(id='output-two') + ) + ) + ) + + app$callback(list(output(id = 'output-state', property = 'children'), + output(id = 'output-two', property = 'children')), + list(input(id = 'submit-button', property = 'n_clicks'), + state(id = 'input-1-state', property = 'value'), + state(id = 'input-2-state', property = 'value')), + function(n_clicks, input1, input2) { + sprintf("The Button has been pressed \"%s\" times, Input 1 is \"%s\", and Input 2 is \"%s\"", n_clicks, input1, input2) + }) + + expect_error( + app$callback(list(output(id = 'output-state', property = 'children'), + output(id = 'output-three', property = 'children')), + list(input(id = 'submit-button2', property = 'n_clicks'), + state(id = 'input-3-state', property = 'value'), + state(id = 'input-4-state', property = 'value')), + function(n_clicks, input1, input2) { + sprintf("The Button has been pressed \"%s\" times, Input 1 is \"%s\", and Input 2 is \"%s\"", n_clicks, input3, input4) + }), + "One or more outputs are duplicated across callbacks. Please ensure that all ID and property combinations are unique." + ) +}) From 923116b44ca7c82e68be3b369860694b35b9dafa Mon Sep 17 00:00:00 2001 From: Ryan Patrick Kyle Date: Fri, 30 Aug 2019 07:49:16 -0400 Subject: [PATCH 19/24] :tshirt: remove whitespace --- R/utils.R | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/R/utils.R b/R/utils.R index 2b982d4d..49eec76c 100644 --- a/R/utils.R +++ b/R/utils.R @@ -361,7 +361,7 @@ insertIntoCallbackMap <- function(map, inputs, output, state, func) { if (length(map) >= 2) { ids <- lapply(names(map), function(x) dash:::getIdProps(x)$ids) props <- lapply(names(map), function(x) dash:::getIdProps(x)$props) - + outputs_as_list <- mapply(paste, ids, props, sep=".", SIMPLIFY = FALSE) if (length(Reduce(intersect, outputs_as_list))) { From 787024e24248f20431b4026fded115025f47daf6 Mon Sep 17 00:00:00 2001 From: Ryan Patrick Kyle Date: Fri, 30 Aug 2019 08:02:01 -0400 Subject: [PATCH 20/24] :pencil2: update CircleCI config for unit tests --- .circleci/config.yml | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/.circleci/config.yml b/.circleci/config.yml index 2a7d6702..d48f0fe0 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -29,7 +29,7 @@ jobs: sudo Rscript -e 'install.packages("remotes"); remotes::install_github("plotly/dashR", dependencies=TRUE, upgrade=TRUE); install.packages(".", type="source", repos=NULL)' - run: - name: ⚙️ run integration test + name: ⚙️ Integration tests command: | python -m venv venv . venv/bin/activate @@ -38,6 +38,11 @@ jobs: export PATH=$PATH:/home/circleci/.local/bin/ pytest tests/integration/ + - run: + name: 🔎 Unit tests + command: | + sudo Rscript -e 'testthat::test_package("dash")' + workflows: version: 2 build: From 8146e5c0794748b06d19a98dda8071a1d27ecd63 Mon Sep 17 00:00:00 2001 From: Ryan Patrick Kyle Date: Fri, 30 Aug 2019 08:05:36 -0400 Subject: [PATCH 21/24] :pencil2: fix indent --- .circleci/config.yml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/.circleci/config.yml b/.circleci/config.yml index d48f0fe0..d5cad757 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -38,10 +38,10 @@ jobs: export PATH=$PATH:/home/circleci/.local/bin/ pytest tests/integration/ - - run: - name: 🔎 Unit tests - command: | - sudo Rscript -e 'testthat::test_package("dash")' + - run: + name: 🔎 Unit tests + command: | + sudo Rscript -e 'testthat::test_package("dash")' workflows: version: 2 From 542af5d1ba6935dcc791b1307a393e7be8b8ba78 Mon Sep 17 00:00:00 2001 From: Ryan Patrick Kyle Date: Fri, 30 Aug 2019 11:12:35 -0400 Subject: [PATCH 22/24] :pencil2: address test loading issue --- .circleci/config.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.circleci/config.yml b/.circleci/config.yml index d5cad757..0fbe421d 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -41,7 +41,7 @@ jobs: - run: name: 🔎 Unit tests command: | - sudo Rscript -e 'testthat::test_package("dash")' + sudo Rscript -e 'testthat::test_dir("tests/")' workflows: version: 2 From 029af1c141ba17220736607dfff8b4c8daeaf507 Mon Sep 17 00:00:00 2001 From: Ryan Patrick Kyle Date: Sun, 1 Sep 2019 13:48:49 -0400 Subject: [PATCH 23/24] :rotating_light: test 1, 2 DashNoUpdate() els --- .../callbacks/test_no_update_multiple.py | 113 ++++++++++++++++++ 1 file changed, 113 insertions(+) create mode 100644 tests/integration/callbacks/test_no_update_multiple.py diff --git a/tests/integration/callbacks/test_no_update_multiple.py b/tests/integration/callbacks/test_no_update_multiple.py new file mode 100644 index 00000000..8aa90542 --- /dev/null +++ b/tests/integration/callbacks/test_no_update_multiple.py @@ -0,0 +1,113 @@ +from selenium.webdriver.support.select import Select + +app = """ +library(dash) +library(dashCoreComponents) +library(dashHtmlComponents) + +app <- Dash$new() + +app$layout( + htmlDiv( + list( + dccInput(id='input-1-state', type='text', value='Montreal'), + dccInput(id='input-2-state', type='text', value='Canada'), + htmlButton(id='submit-button', n_clicks=0, children='Submit'), + dccChecklist(id='count-inputs', + options=list( + list(label = 'Update state', value = 'states'), + list(label = 'Update clicks', value = 'clicks') + ), + value=list('states', 'clicks') + ), + htmlDiv(id='output-state'), + htmlDiv(id='output-clicks') + ) + ) +) + +app$callback(output= + list( + output(id = 'output-state', property = 'children'), + output(id = 'output-clicks', property = 'children') + ), + list(input(id = 'submit-button', property = 'n_clicks'), + input(id = 'count-inputs', property = 'value'), + state(id = 'input-1-state', property = 'value'), + state(id = 'input-2-state', property = 'value')), + function(n_clicks, count, input1, input2) { + states <- sprintf("Input 1 is %s, and Input 2 is %s", input1, input2) + clicks <- sprintf("The Button has been pressed %s times.", n_clicks) + + if (all(list("states", "clicks") %in% count)) { + return(list(states, + clicks + ) + ) + } else if ("states" %in% count) { + return(list(states, + dashNoUpdate() + ) + ) + } else if ("clicks" %in% count) { + return(list(dashNoUpdate(), + clicks + ) + ) + } else { + return(list(dashNoUpdate(), + dashNoUpdate())) + } + } +) + +app$run_server(debug=TRUE) +""" + + +def test_rsnu002_no_update_multiple(dashr): + dashr.start_server(app) + input1 = dashr.find_element("#input-1-state") + dashr.clear_input(input1) + input1.send_keys("Quebec") + dashr.find_element("#submit-button").click() + dashr.wait_for_text_to_equal( + "#output-state", + 'Input 1 is Quebec, and Input 2 is Canada' + ) + dashr.wait_for_text_to_equal( + "#output-clicks", + 'The Button has been pressed 1 times.' + ) + dashr.find_elements("input[type='checkbox']")[0].click() + dashr.clear_input(input1) + input1.send_keys("Montreal") + dashr.find_element("#submit-button").click() + dashr.wait_for_text_to_equal( + "#output-state", + 'Input 1 is Quebec, and Input 2 is Canada' + ) + dashr.wait_for_text_to_equal( + "#output-clicks", + 'The Button has been pressed 2 times.' + ) + dashr.find_elements("input[type='checkbox']")[1].click() + dashr.find_element("#submit-button").click() + dashr.wait_for_text_to_equal( + "#output-state", + 'Input 1 is Quebec, and Input 2 is Canada' + ) + dashr.wait_for_text_to_equal( + "#output-clicks", + 'The Button has been pressed 2 times.' + ) + dashr.find_elements("input[type='checkbox']")[0].click() + dashr.find_elements("input[type='checkbox']")[1].click() + dashr.wait_for_text_to_equal( + "#output-state", + 'Input 1 is Montreal, and Input 2 is Canada' + ) + dashr.wait_for_text_to_equal( + "#output-clicks", + 'The Button has been pressed 3 times.' + ) From 80718666d70c268f192481387af797a14c6beafc Mon Sep 17 00:00:00 2001 From: Ryan Patrick Kyle Date: Tue, 3 Sep 2019 09:21:18 -0400 Subject: [PATCH 24/24] :pencil2: add comments --- tests/integration/callbacks/test_no_update_multiple.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/tests/integration/callbacks/test_no_update_multiple.py b/tests/integration/callbacks/test_no_update_multiple.py index 8aa90542..5f471a97 100644 --- a/tests/integration/callbacks/test_no_update_multiple.py +++ b/tests/integration/callbacks/test_no_update_multiple.py @@ -79,6 +79,8 @@ def test_rsnu002_no_update_multiple(dashr): "#output-clicks", 'The Button has been pressed 1 times.' ) + # Now only the Update clicks checkbox is active, so + # state should not update dashr.find_elements("input[type='checkbox']")[0].click() dashr.clear_input(input1) input1.send_keys("Montreal") @@ -91,6 +93,7 @@ def test_rsnu002_no_update_multiple(dashr): "#output-clicks", 'The Button has been pressed 2 times.' ) + # Neither checkbox is selected, so neither output should update dashr.find_elements("input[type='checkbox']")[1].click() dashr.find_element("#submit-button").click() dashr.wait_for_text_to_equal( @@ -101,6 +104,7 @@ def test_rsnu002_no_update_multiple(dashr): "#output-clicks", 'The Button has been pressed 2 times.' ) + # Now both are selected, so both state and clicks should update dashr.find_elements("input[type='checkbox']")[0].click() dashr.find_elements("input[type='checkbox']")[1].click() dashr.wait_for_text_to_equal(