diff --git a/NAMESPACE b/NAMESPACE index b8695e1796..3eb78536df 100644 --- a/NAMESPACE +++ b/NAMESPACE @@ -41,7 +41,6 @@ export(validate_has_elements) export(validate_has_variable) export(validate_in) export(validate_inputs) -export(validate_inputs_segregated) export(validate_n_levels) export(validate_no_intersection) export(validate_one_row_per_id) diff --git a/NEWS.md b/NEWS.md index 16c1039417..1696865cfb 100644 --- a/NEWS.md +++ b/NEWS.md @@ -10,7 +10,7 @@ ### New features -* Added the `validate_inputs` and `validate_inputs_segregated` functions that transfer input validation messages to app output. +* Added the `validate_inputs` function that transfers input validation messages to app output. * `modules` argument of `init` accepts `teal_module` type of object. There is no need to wrap up a single module in `modules()` or `list()`. ### Miscellaneous diff --git a/R/validate_inputs.R b/R/validate_inputs.R index d1a4469db7..5b66f02b86 100644 --- a/R/validate_inputs.R +++ b/R/validate_inputs.R @@ -1,5 +1,5 @@ -#' Send input validation messages to output +#' Send input validation messages to output. #' #' Captures messages from `InputValidator` objects and collates them #' into one message passed to `validate`. @@ -9,30 +9,28 @@ #' of the output element. #' `shinyvalidate::InputValidator` allows to validate input elements #' and to display specific messages in their respective input widgets. -#' This function is a hybrid solution. Given an `InputValidator` object, -#' it extracts messages from inputs that fail validation and places them all in one -#' validation message that is passed to a `validate`/`need` call. +#' `validate_inputs` provides a hybrid solution. +#' Given an `InputValidator` object, messages corresponding to inputs that fail validation +#' are extracted and placed in one validation message that is passed to a `validate`/`need` call. #' This way the input validator messages are repeated in the output. #' -#' `validate_inputs` accepts an arbitrary number of `InputValidator`s -#' and prints all messages together, adding one (optional) header. -#' `validate_inputs_segregated` accepts a list of `InputValidator`s -#' and prints messages grouped by validator. If elements of `validators` are named, - -#' the names are used as headers for their respective message groups. -#' +#' The `...` argument accepts any number of `InputValidator` objects +#' or a nested list of such objects. +#' If validators are passed directly, all their messages are printed together +#' under one (optional) header message specified by `header`. If a list is passed, +#' messages are grouped by validator. The list's names are used as headers +#' for their respective message groups. +#' If neither of the nested list elements is named, a header message is taken from `header`. #' -#' @name validate_inputs -#' -#' @param ... for `validate_inputs` any number of `InputValidator` objects \cr -#' for `validate_inputs_segregated` arguments passed to `validate` -#' @param header `character(1)` optional generic validation message -#' @param validators optionally named `list` of `InputValidator` objects, see `Details` +#' @param ... either any number of `InputValidator` objects +#' or an optionally named, possibly nested `list` of `InputValidator` +#' objects, see `Details` +#' @param header `character(1)` generic validation message; set to NULL to omit #' #' @return #' Returns NULL if the final validation call passes and a `shiny.silent.error` if it fails. #' -#' @seealso [`shinyvalidate::InputValidator`] [`shiny::validate`] +#' @seealso [`shinyvalidate::InputValidator`], [`shiny::validate`] #' #' @examples #' library(shiny) @@ -84,7 +82,7 @@ #' validate_inputs(iv_par, header = "Set proper graphical parameters") #' }, #' "combined" = validate_inputs(iv, iv_par), -#' "grouped" = validate_inputs_segregated(list( +#' "grouped" = validate_inputs(list( #' "Some inputs require attention" = iv, #' "Set proper graphical parameters" = iv_par #' )) @@ -100,64 +98,84 @@ #' if (interactive()) { #' shinyApp(ui, server) #' } - -#' @rdname validate_inputs +#' #' @export +#' validate_inputs <- function(..., header = "Some inputs require attention") { - vals <- list(...) - lapply(vals, checkmate::assert_class, "InputValidator") - checkmate::assert_string(header, null.ok = TRUE) + dots <- list(...) + if (!is_validators(dots)) stop("validate_inputs accepts validators or a list thereof") - fail_messages <- unlist(lapply(vals, gather_messages)) - failings <- add_header(fail_messages, header) + messages <- extract_validator(dots, header) + failings <- if (!any_names(dots)) { + add_header(messages, header) + } else { + unlist(messages) + } shiny::validate(shiny::need(is.null(failings), failings)) } +### internal functions -#' @rdname validate_inputs -#' @export -validate_inputs_segregated <- function(validators, ...) { - checkmate::assert_list(validators, types = "InputValidator") - - # Since some or all names may be NULL, mapply cannot be used here, a loop is required. - fail_messages <- vector("list", length(validators)) - for (v in seq_along(validators)) { - fail_messages[[v]] <- gather_and_add(validators[[v]], names(validators)[v]) - } - - failings <- unlist(fail_messages) - - shiny::validate(shiny::need(is.null(failings), failings), ...) +#' @keywords internal +# recursive object type test +# returns logical of length 1 +is_validators <- function(x) { + all(if (is.list(x)) unlist(lapply(x, is_validators)) else inherits(x, "InputValidator")) } +#' @keywords internal +# test if an InputValidator object is enabled +# returns logical of length 1 +# official method requested at https://github.com/rstudio/shinyvalidate/issues/64 +validator_enabled <- function(x) { + x$.__enclos_env__$private$enabled +} -### internal functions +#' @keywords internal +# recursively extract messages from validator list +# returns character vector or a list of character vectors, possibly nested and named +extract_validator <- function(iv, header) { + if (inherits(iv, "InputValidator")) { + add_header(gather_messages(iv), header) + } else { + if (is.null(names(iv))) names(iv) <- rep("", length(iv)) + mapply(extract_validator, iv = iv, header = names(iv), SIMPLIFY = FALSE) + } +} #' @keywords internal # collate failing messages from validator +# returns list gather_messages <- function(iv) { - status <- iv$validate() - failing_inputs <- Filter(Negate(is.null), status) - unique(lapply(failing_inputs, function(x) x[["message"]])) + if (validator_enabled(iv)) { + status <- iv$validate() + failing_inputs <- Filter(Negate(is.null), status) + unique(lapply(failing_inputs, function(x) x[["message"]])) + } else { + logger::log_warn("Validator is disabled and will be omitted.") + list() + } } - #' @keywords internal -# format failing messages with optional header message -add_header <- function(messages, header) { - if (length(messages) > 0L) { - c(paste0(header, "\n"), unlist(messages), "\n") - } else { - NULL +# add optional header to failing messages +add_header <- function(messages, header = "") { + ans <- unlist(messages) + if (length(ans) != 0L && isTRUE(nchar(header) > 0L)) { + ans <- c(paste0(header, "\n"), ans, "\n") } + ans } #' @keywords internal -# collate failing messages with optional header message -# used by segregated method -gather_and_add <- function(iv, header) { - fail_messages <- gather_messages(iv) - failings <- add_header(fail_messages, header) - failings +# recursively check if the object contains a named list +any_names <- function(x) { + any( + if (is.list(x)) { + if (!is.null(names(x)) && any(names(x) != "")) TRUE else unlist(lapply(x, any_names)) + } else { + FALSE + } + ) } diff --git a/_pkgdown.yml b/_pkgdown.yml index 1898352a18..7d96c4e7cb 100644 --- a/_pkgdown.yml +++ b/_pkgdown.yml @@ -52,7 +52,6 @@ reference: - title: Validation functions contents: - starts_with("validate_") - - starts_with("validate_inputs") - title: Deprecated functions contents: - get_rcode diff --git a/man/validate_inputs.Rd b/man/validate_inputs.Rd index 139a83df1d..5f31a495f5 100644 --- a/man/validate_inputs.Rd +++ b/man/validate_inputs.Rd @@ -2,20 +2,16 @@ % Please edit documentation in R/validate_inputs.R \name{validate_inputs} \alias{validate_inputs} -\alias{validate_inputs_segregated} -\title{Send input validation messages to output} +\title{Send input validation messages to output.} \usage{ validate_inputs(..., header = "Some inputs require attention") - -validate_inputs_segregated(validators, ...) } \arguments{ -\item{...}{for \code{validate_inputs} any number of \code{InputValidator} objects \cr -for \code{validate_inputs_segregated} arguments passed to \code{validate}} - -\item{header}{\code{character(1)} optional generic validation message} +\item{...}{either any number of \code{InputValidator} objects +or an optionally named, possibly nested \code{list} of \code{InputValidator} +objects, see \code{Details}} -\item{validators}{optionally named \code{list} of \code{InputValidator} objects, see \code{Details}} +\item{header}{\code{character(1)} generic validation message; set to NULL to omit} } \value{ Returns NULL if the final validation call passes and a \code{shiny.silent.error} if it fails. @@ -30,16 +26,18 @@ certain conditions are met and to print a validation message in place of the output element. \code{shinyvalidate::InputValidator} allows to validate input elements and to display specific messages in their respective input widgets. -This function is a hybrid solution. Given an \code{InputValidator} object, -it extracts messages from inputs that fail validation and places them all in one -validation message that is passed to a \code{validate}/\code{need} call. +\code{validate_inputs} provides a hybrid solution. +Given an \code{InputValidator} object, messages corresponding to inputs that fail validation +are extracted and placed in one validation message that is passed to a \code{validate}/\code{need} call. This way the input validator messages are repeated in the output. -\code{validate_inputs} accepts an arbitrary number of \code{InputValidator}s -and prints all messages together, adding one (optional) header. -\code{validate_inputs_segregated} accepts a list of \code{InputValidator}s -and prints messages grouped by validator. If elements of \code{validators} are named, -the names are used as headers for their respective message groups. +The \code{...} argument accepts any number of \code{InputValidator} objects +or a nested list of such objects. +If validators are passed directly, all their messages are printed together +under one (optional) header message specified by \code{header}. If a list is passed, +messages are grouped by validator. The list's names are used as headers +for their respective message groups. +If neither of the nested list elements is named, a header message is taken from \code{header}. } \examples{ library(shiny) @@ -91,7 +89,7 @@ server <- function(input, output) { validate_inputs(iv_par, header = "Set proper graphical parameters") }, "combined" = validate_inputs(iv, iv_par), - "grouped" = validate_inputs_segregated(list( + "grouped" = validate_inputs(list( "Some inputs require attention" = iv, "Set proper graphical parameters" = iv_par )) @@ -107,7 +105,8 @@ server <- function(input, output) { if (interactive()) { shinyApp(ui, server) } + } \seealso{ -\code{\link[shinyvalidate:InputValidator]{shinyvalidate::InputValidator}} \code{\link[shiny:validate]{shiny::validate}} +\code{\link[shinyvalidate:InputValidator]{shinyvalidate::InputValidator}}, \code{\link[shiny:validate]{shiny::validate}} } diff --git a/tests/testthat/test-get_rcode.R b/tests/testthat/test-get_rcode.R index ad2435c12f..bdcfcf0f39 100644 --- a/tests/testthat/test-get_rcode.R +++ b/tests/testthat/test-get_rcode.R @@ -40,7 +40,7 @@ testthat::test_that("get_rcode returns data-loading, filter-panel and chunks cod "MTCARS <- mtcars", "a <- 1" ) %in% - strsplit(get_rcode(datasets = datasets, chunks = ch), "\n")[[1]] + strsplit(shiny::isolate(get_rcode(datasets = datasets, chunks = ch)), "\n")[[1]] ) ) }) diff --git a/tests/testthat/test-module_nested_tabs.R b/tests/testthat/test-module_nested_tabs.R index 3f53d6fc24..4eecf4d400 100644 --- a/tests/testthat/test-module_nested_tabs.R +++ b/tests/testthat/test-module_nested_tabs.R @@ -408,7 +408,7 @@ testthat::test_that("calculate_hashes returns the hash of the non Filtered datas Species = c("setosa", "versicolor") ) ) - datasets$set_filter_state(state = fs) + shiny::isolate(datasets$set_filter_state(state = fs)) hashes <- calculate_hashes(datanames = c("iris"), datasets = datasets) testthat::expect_identical(hashes, list("iris" = "34844aba7bde36f5a34f6d8e39803508")) diff --git a/tests/testthat/test-validate_inputs.R b/tests/testthat/test-validate_inputs.R index c555e47dcd..b9ed3e66aa 100644 --- a/tests/testthat/test-validate_inputs.R +++ b/tests/testthat/test-validate_inputs.R @@ -1,11 +1,88 @@ testthat::test_that("invalid arguments raise errors", { - testthat::expect_error(validate_inputs("string")) - testthat::expect_error(validate_inputs_segregated(list("name" = "string"))) + testthat::expect_error(validate_inputs("string"), + "validate_inputs accepts validators or a list thereof") + testthat::expect_error(validate_inputs(list("name" = "string")), + "validate_inputs accepts validators or a list thereof") }) -testthat::test_that("validate_inputs: valid inputs produce desired output", { +testthat::test_that("disabled validators raise warnings (individual validators)", { + server <- function(input, output, session) { + iv1 <- shinyvalidate::InputValidator$new() + iv1$add_rule("letter", shinyvalidate::sv_in_set(LETTERS, "choose a capital letter")) + iv1$enable() + iv2 <- shinyvalidate::InputValidator$new() + iv2$add_rule("number", ~ if (as.integer(.) %% 2L == 1L) "choose an even number") + values <- shiny::reactive({ + validate_inputs(iv1, iv2) + list( + "letter" = input[["letter"]], + "number" = input[["number"]] + ) + }) + } + + shiny::testServer(server, { + testthat::expect_output( + object = values(), + regexp = "\\[WARN\\].+Validator is disabled and will be omitted." + ) + }) +}) + + +testthat::test_that("disabled validators raise warnings (validator list)", { + server <- function(input, output, session) { + iv1 <- shinyvalidate::InputValidator$new() + iv1$add_rule("letter", shinyvalidate::sv_in_set(LETTERS, "choose a capital letter")) + iv1$enable() + iv2 <- shinyvalidate::InputValidator$new() + iv2$add_rule("number", ~ if (as.integer(.) %% 2L == 1L) "choose an even number") + values <- shiny::reactive({ + validate_inputs(list(iv1, iv2)) + list( + "letter" = input[["letter"]], + "number" = input[["number"]] + ) + }) + } + + shiny::testServer(server, { + testthat::expect_output( + object = values(), + regexp = "\\[WARN\\].+Validator is disabled and will be omitted." + ) + }) +}) + + +testthat::test_that("disabled validators raise warnings (nested validator list)", { + server <- function(input, output, session) { + iv1 <- shinyvalidate::InputValidator$new() + iv1$add_rule("letter", shinyvalidate::sv_in_set(LETTERS, "choose a capital letter")) + iv1$enable() + iv2 <- shinyvalidate::InputValidator$new() + iv2$add_rule("number", ~ if (as.integer(.) %% 2L == 1L) "choose an even number") + values <- shiny::reactive({ + validate_inputs(list(list(iv1), list(iv2))) + list( + "letter" = input[["letter"]], + "number" = input[["number"]] + ) + }) + } + + shiny::testServer(server, { + testthat::expect_output( + object = values(), + regexp = "\\[WARN\\].+Validator is disabled and will be omitted." + ) + }) +}) + + +testthat::test_that("valid inputs produce desired output (individual validators)", { server <- function(input, output, session) { iv <- shinyvalidate::InputValidator$new() iv$add_rule("letter", shinyvalidate::sv_in_set(LETTERS, "choose a capital letter")) @@ -33,14 +110,42 @@ testthat::test_that("validate_inputs: valid inputs produce desired output", { }) -testthat::test_that("validate_inputs_segregated: valid inputs produce desired output", { +testthat::test_that("valid inputs produce desired output (validator list)", { + server <- function(input, output, session) { + iv <- shinyvalidate::InputValidator$new() + iv$add_rule("letter", shinyvalidate::sv_in_set(LETTERS, "choose a capital letter")) + iv$add_rule("number", ~ if (as.integer(.) %% 2L == 1L) "choose an even number") + iv$enable() + values <- shiny::reactive({ + validate_inputs(list(iv)) + list( + "letter" = input[["letter"]], + "number" = input[["number"]] + ) + }) + } + + shiny::testServer(server, { + session$setInputs( + "letter" = "A", + "number" = 2L + ) + testthat::expect_identical(values(), list( + "letter" = input[["letter"]], + "number" = input[["number"]] + )) + }) +}) + + +testthat::test_that("valid inputs produce desired output (nested validator list)", { server <- function(input, output, session) { iv <- shinyvalidate::InputValidator$new() iv$add_rule("letter", shinyvalidate::sv_in_set(LETTERS, "choose a capital letter")) iv$add_rule("number", ~ if (as.integer(.) %% 2L == 1L) "choose an even number") iv$enable() values <- shiny::reactive({ - validate_inputs_segregated(list(iv)) + validate_inputs(list(list(iv))) list( "letter" = input[["letter"]], "number" = input[["number"]] @@ -61,7 +166,7 @@ testthat::test_that("validate_inputs_segregated: valid inputs produce desired ou }) -testthat::test_that("validate_inputs: invalid inputs raise errors in output", { +testthat::test_that("invalid inputs raise errors in output (individual validators)", { server <- function(input, output, session) { iv <- shinyvalidate::InputValidator$new() iv$add_rule("letter", shinyvalidate::sv_in_set(LETTERS, "choose a capital letter")) @@ -81,33 +186,71 @@ testthat::test_that("validate_inputs: invalid inputs raise errors in output", { "letter" = "a", "number" = 2L ) - testthat::expect_error(values()) + testthat::expect_error(values(), "choose a capital letter") }) shiny::testServer(server, { session$setInputs( "letter" = "A", "number" = 1L ) - testthat::expect_error(values()) + testthat::expect_error(values(), "choose an even number") }) shiny::testServer(server, { session$setInputs( "letter" = "a", "number" = 1L ) - testthat::expect_error(values()) + testthat::expect_error(values(), "choose a capital letter.+choose an even number") }) }) -testthat::test_that("validate_inputs_segregated: invalid inputs raise errors in output", { +testthat::test_that("invalid inputs raise errors in output (validator list)", { + server <- function(input, output, session) { + iv <- shinyvalidate::InputValidator$new() + iv$add_rule("letter", shinyvalidate::sv_in_set(LETTERS, "choose a capital letter")) + iv$add_rule("number", ~ if (as.integer(.) %% 2L == 1L) "choose an even number") + iv$enable() + values <- shiny::reactive({ + validate_inputs(list(iv)) + list( + "letter" = input[["letter"]], + "number" = input[["number"]] + ) + }) + } + + shiny::testServer(server, { + session$setInputs( + "letter" = "a", + "number" = 2L + ) + testthat::expect_error(values(), "choose a capital letter") + }) + shiny::testServer(server, { + session$setInputs( + "letter" = "A", + "number" = 1L + ) + testthat::expect_error(values(), "choose an even number") + }) + shiny::testServer(server, { + session$setInputs( + "letter" = "a", + "number" = 1L + ) + testthat::expect_error(values(), "choose a capital letter.+choose an even number") + }) +}) + +testthat::test_that("invalid inputs raise errors in output (nested validator list)", { server <- function(input, output, session) { iv <- shinyvalidate::InputValidator$new() iv$add_rule("letter", shinyvalidate::sv_in_set(LETTERS, "choose a capital letter")) iv$add_rule("number", ~ if (as.integer(.) %% 2L == 1L) "choose an even number") iv$enable() values <- shiny::reactive({ - validate_inputs_segregated(list(iv)) + validate_inputs(list(list(iv))) list( "letter" = input[["letter"]], "number" = input[["number"]] @@ -120,21 +263,21 @@ testthat::test_that("validate_inputs_segregated: invalid inputs raise errors in "letter" = "a", "number" = 2L ) - testthat::expect_error(values()) + testthat::expect_error(values(), "choose a capital letter") }) shiny::testServer(server, { session$setInputs( "letter" = "A", "number" = 1L ) - testthat::expect_error(values()) + testthat::expect_error(values(), "choose an even number") }) shiny::testServer(server, { session$setInputs( "letter" = "a", "number" = 1L ) - testthat::expect_error(values()) + testthat::expect_error(values(), "choose a capital letter.+choose an even number") }) }) @@ -164,17 +307,13 @@ testthat::test_that("error message is formatted properly", { "color" = "", "size" = 0.25 ) + # check error class - testthat::expect_error(validate_inputs(iv)) - testthat::expect_error(validate_inputs(iv, iv_par)) - testthat::expect_error(validate_inputs_segregated(list(iv)), class = "shiny.silent.error") - testthat::expect_error(validate_inputs_segregated(list(iv, iv_par)), class = "shiny.silent.error") - testthat::expect_error(validate_inputs_segregated(list(iv), errorClass = "custom.error.class"), - class = "custom.error.class" - ) - testthat::expect_error(validate_inputs_segregated(list(iv, iv_par), errorClass = "custom.error.class"), - class = "custom.error.class" - ) + testthat::expect_error(validate_inputs(iv), class = "shiny.silent.error") + testthat::expect_error(validate_inputs(iv, iv_par), class = "shiny.silent.error") + testthat::expect_error(validate_inputs(list(iv)), class = "shiny.silent.error") + testthat::expect_error(validate_inputs(list(iv, iv_par)), class = "shiny.silent.error") + testthat::expect_error(validate_inputs(list(iv, list(iv_par))), class = "shiny.silent.error") # check error message errmess <- tryCatch(validate_inputs(iv), error = function(e) e$message) @@ -201,10 +340,10 @@ testthat::test_that("error message is formatted properly", { collapse = "\n" )) - errmess <- tryCatch(validate_inputs_segregated(list(iv)), error = function(e) e$message) + errmess <- tryCatch(validate_inputs(list(iv)), error = function(e) e$message) testthat::expect_identical(errmess, paste( c( - "\n", + "Some inputs require attention\n", "choose a capital letter", "choose an even number", "\n" @@ -212,14 +351,25 @@ testthat::test_that("error message is formatted properly", { collapse = "\n" )) - errmess <- tryCatch(validate_inputs_segregated(list(iv, iv_par)), error = function(e) e$message) + errmess <- tryCatch(validate_inputs(list(iv, iv_par)), error = function(e) e$message) testthat::expect_identical(errmess, paste( c( - "\n", + "Some inputs require attention\n", + "choose a capital letter", + "choose an even number", + "choose a color", + "choose a value between 0.5 and 3", + "\n" + ), + collapse = "\n" + )) + + errmess <- tryCatch(validate_inputs(list(iv, list(iv_par))), error = function(e) e$message) + testthat::expect_identical(errmess, paste( + c( + "Some inputs require attention\n", "choose a capital letter", "choose an even number", - "\n", - "\n", "choose a color", "choose a value between 0.5 and 3", "\n" @@ -252,7 +402,7 @@ testthat::test_that("error message is formatted properly", { collapse = "\n" )) - errmess <- tryCatch(validate_inputs_segregated(list("Header message" = iv)), error = function(e) e$message) + errmess <- tryCatch(validate_inputs(list("Header message" = iv)), error = function(e) e$message) testthat::expect_identical(errmess, paste( c( "Header message\n", @@ -264,7 +414,7 @@ testthat::test_that("error message is formatted properly", { )) errmess <- tryCatch( - validate_inputs_segregated(list( + validate_inputs(list( "Header message 1" = iv, "Header message 2" = iv_par )), @@ -283,6 +433,48 @@ testthat::test_that("error message is formatted properly", { ), collapse = "\n" )) + + errmess <- tryCatch( + validate_inputs(list( + "Header message 1" = iv, + list( + "Header message 2" = iv_par + ) + )), + error = function(e) e$message + ) + testthat::expect_identical(errmess, paste( + c( + "Header message 1\n", + "choose a capital letter", + "choose an even number", + "\n", + "Header message 2\n", + "choose a color", + "choose a value between 0.5 and 3", + "\n" + ), + collapse = "\n" + )) + + errmess <- tryCatch( + validate_inputs(list( + iv, + "Header message" = iv_par + )), + error = function(e) e$message + ) + testthat::expect_identical(errmess, paste( + c( + "choose a capital letter", + "choose an even number", + "Header message\n", + "choose a color", + "choose a value between 0.5 and 3", + "\n" + ), + collapse = "\n" + )) }) }) @@ -324,7 +516,7 @@ testthat::test_that("different validation modes produce proper messages", { ) }) values_g <- shiny::reactive({ - validate_inputs_segregated(list("Main validator" = iv, "Graphical validator" = iv_par)) + validate_inputs(list("Main validator" = iv, "Graphical validator" = iv_par)) list( "letter" = input[["letter"]], "number" = input[["number"]],