diff --git a/DESCRIPTION b/DESCRIPTION index df8eb4862..1c624f9ce 100644 --- a/DESCRIPTION +++ b/DESCRIPTION @@ -116,10 +116,10 @@ Collate: 'module_session_info.R' 'module_snapshot_manager.R' 'module_teal.R' - 'module_teal_data.R' 'module_teal_lockfile.R' 'module_teal_with_splash.R' 'module_transform_data.R' + 'module_validate.R' 'reporter_previewer_module.R' 'show_rcode_modal.R' 'tdata.R' diff --git a/R/module_init_data.R b/R/module_init_data.R index 8ad8800ef..d99119bce 100644 --- a/R/module_init_data.R +++ b/R/module_init_data.R @@ -20,7 +20,7 @@ #' lies in data control: the first method involves external control, while the second method #' involves control from a custom module within the app. #' -#' For more details, see [`module_teal_data`]. +#' For more details, see [`teal_data_module`]. #' #' @inheritParams module_teal #' diff --git a/R/module_nested_tabs.R b/R/module_nested_tabs.R index 59e819a2a..539391856 100644 --- a/R/module_nested_tabs.R +++ b/R/module_nested_tabs.R @@ -95,22 +95,9 @@ ui_teal_module.teal_module <- function(id, modules, depth = 0L) { args <- c(list(id = ns("module")), modules$ui_args) ui_teal <- tags$div( - shinyjs::hidden( - tags$div( - id = ns("transform_failure_info"), - class = "teal_validated", - div( - class = "teal-output-warning", - "One of transformators failed. Please check its inputs." - ) - ) - ), + ui_module_validate(ns("validation")), tags$div( id = ns("teal_module_ui"), - tags$div( - class = "teal_validated", - ui_check_module_datanames(ns("validate_datanames")) - ), do.call(what = modules$ui, args = args, quote = TRUE) ) ) @@ -348,16 +335,6 @@ srv_teal_module.teal_module <- function(id, any(unlist(reactiveValuesToList(is_transform_failed))) }) - observeEvent(any_transform_failed(), { - if (isTRUE(any_transform_failed())) { - shinyjs::hide("teal_module_ui") - shinyjs::show("transform_failure_info") - } else { - shinyjs::show("teal_module_ui") - shinyjs::hide("transform_failure_info") - } - }) - module_teal_data <- reactive({ req(inherits(transformed_teal_data(), "teal_data")) all_teal_data <- transformed_teal_data() @@ -365,12 +342,20 @@ srv_teal_module.teal_module <- function(id, all_teal_data[c(module_datanames, ".raw_data")] }) - srv_check_module_datanames( - "validate_datanames", - data = module_teal_data, - modules = modules + srv_module_validate_datanames( + "validation", + x = module_teal_data, + modules = modules, + show_warn = any_transform_failed, + message_warn = "One of the transformators failed. Please check its inputs." ) + observe({ # Blur and disable main module UI when there are errors with reactive teal_data + shinyjs::show("teal_module_ui") + shinyjs::toggleClass("teal_module_ui", "blurred", condition = any_transform_failed()) + shinyjs::toggleState("teal_module_ui", condition = !any_transform_failed()) + }) + summary_table <- srv_data_summary("data_summary", module_teal_data) observeEvent(input$data_summary_toggle, { diff --git a/R/module_teal.R b/R/module_teal.R index 79218ff9a..81716a7de 100644 --- a/R/module_teal.R +++ b/R/module_teal.R @@ -136,16 +136,10 @@ srv_teal <- function(id, data, modules, filter = teal_slices()) { data_handled <- srv_init_data("data", data = data) - validate_ui <- tags$div( - id = session$ns("validate_messages"), - class = "teal_validated", - ui_check_class_teal_data(session$ns("class_teal_data")), - ui_validate_error(session$ns("silent_error")), - ui_check_module_datanames(session$ns("datanames_warning")) + validate_ui <- ui_module_validate(session$ns("validation")) + srv_module_validate_teal_module( + "validation", x = data_handled, validate_shiny_silent_error = FALSE, modules = modules ) - srv_check_class_teal_data("class_teal_data", data_handled) - srv_validate_error("silent_error", data_handled, validate_shiny_silent_error = FALSE) - srv_check_module_datanames("datanames_warning", data_handled, modules) data_validated <- .trigger_on_success(data_handled) @@ -181,8 +175,6 @@ srv_teal <- function(id, data, modules, filter = teal_slices()) { }) } - - if (inherits(data, "teal_data_module")) { setBookmarkExclude(c("teal_modules-active_tab")) bslib::nav_insert( @@ -192,10 +184,7 @@ srv_teal <- function(id, data, modules, filter = teal_slices()) { bslib::nav_panel( title = icon("fas fa-database"), value = "teal_data_module", - tags$div( - ui_init_data(session$ns("data")), - validate_ui - ) + tags$div(validate_ui, ui_init_data(session$ns("data"))) ) ) @@ -234,3 +223,15 @@ srv_teal <- function(id, data, modules, filter = teal_slices()) { invisible(NULL) } + +.trigger_on_success <- function(data) { + out <- reactiveVal(NULL) + observeEvent(data(), { + if (inherits(data(), "teal_data")) { + if (!identical(data(), out())) { + out(data()) + } + } + }) + out +} diff --git a/R/module_teal_data.R b/R/module_teal_data.R deleted file mode 100644 index ff42ab84f..000000000 --- a/R/module_teal_data.R +++ /dev/null @@ -1,252 +0,0 @@ -#' Execute and validate `teal_data_module` -#' -#' This is a low level module to handle `teal_data_module` execution and validation. -#' [teal_transform_module()] inherits from [teal_data_module()] so it is handled by this module too. -#' [srv_teal()] accepts various `data` objects and eventually they are all transformed to `reactive` -#' [teal.data::teal_data()] which is a standard data class in whole `teal` framework. -#' -#' @section data validation: -#' -#' Executed [teal_data_module()] is validated and output is validated for consistency. -#' Output `data` is invalid if: -#' 1. [teal_data_module()] is invalid if server doesn't return `reactive`. **Immediately crashes an app!** -#' 2. `reactive` throws a `shiny.error` - happens when module creating [teal.data::teal_data()] fails. -#' 3. `reactive` returns `qenv.error` - happens when [teal.data::teal_data()] evaluates a failing code. -#' 4. `reactive` object doesn't return [teal.data::teal_data()]. -#' 5. [teal.data::teal_data()] object lacks any `datanames` specified in the `modules` argument. -#' -#' `teal` (observers in `srv_teal`) always waits to render an app until `reactive` `teal_data` is -#' returned. If error 2-4 occurs, relevant error message is displayed to the app user. Once the issue is -#' resolved, the app will continue to run. `teal` guarantees that errors in data don't crash the app -#' (except error 1). -#' -#' @inheritParams module_teal_module -#' @param data_module (`teal_data_module`) -#' @param modules (`teal_modules` or `teal_module`) For `datanames` validation purpose -#' @param validate_shiny_silent_error (`logical`) If `TRUE`, then `shiny.silent.error` is validated and -#' @param is_transform_failed (`reactiveValues`) contains `logical` flags named after each transformator. -#' Help to determine if any previous transformator failed, so that following transformators can be disabled -#' and display a generic failure message. -#' -#' @return `reactive` `teal_data` -#' -#' @rdname module_teal_data -#' @name module_teal_data -#' @keywords internal -NULL - -#' @rdname module_teal_data -#' @aliases ui_teal_data -#' @note -#' `ui_teal_data_module` was renamed from `ui_teal_data`. -ui_teal_data_module <- function(id, data_module = function(id) NULL) { - checkmate::assert_string(id) - checkmate::assert_function(data_module, args = "id") - ns <- NS(id) - - shiny::tagList( - tags$div(id = ns("wrapper"), data_module(id = ns("data"))), - ui_validate_reactive_teal_data(ns("validate")) - ) -} - -#' @rdname module_teal_data -#' @aliases srv_teal_data -#' @note -#' `srv_teal_data_module` was renamed from `srv_teal_data`. -srv_teal_data_module <- function(id, - data_module = function(id) NULL, - modules = NULL, - validate_shiny_silent_error = TRUE, - is_transform_failed = reactiveValues()) { - checkmate::assert_string(id) - checkmate::assert_function(data_module, args = "id") - checkmate::assert_multi_class(modules, c("teal_modules", "teal_module"), null.ok = TRUE) - checkmate::assert_class(is_transform_failed, "reactivevalues") - - moduleServer(id, function(input, output, session) { - logger::log_debug("srv_teal_data_module initializing.") - is_transform_failed[[id]] <- FALSE - module_out <- data_module(id = "data") - try_module_out <- reactive(tryCatch(module_out(), error = function(e) e)) - observeEvent(try_module_out(), { - if (!inherits(try_module_out(), "teal_data")) { - is_transform_failed[[id]] <- TRUE - } else { - is_transform_failed[[id]] <- FALSE - } - }) - - is_previous_failed <- reactive({ - idx_this <- which(names(is_transform_failed) == id) - is_transform_failed_list <- reactiveValuesToList(is_transform_failed) - idx_failures <- which(unlist(is_transform_failed_list)) - any(idx_failures < idx_this) - }) - - observeEvent(is_previous_failed(), { - if (is_previous_failed()) { - shinyjs::disable("wrapper") - } else { - shinyjs::enable("wrapper") - } - }) - - srv_validate_reactive_teal_data( - "validate", - data = try_module_out, - modules = modules, - validate_shiny_silent_error = validate_shiny_silent_error, - hide_validation_error = is_previous_failed - ) - }) -} - -#' @rdname module_teal_data -ui_validate_reactive_teal_data <- function(id) { - ns <- NS(id) - tags$div( - div( - id = ns("validate_messages"), - class = "teal_validated", - ui_validate_error(ns("silent_error")), - ui_check_class_teal_data(ns("class_teal_data")), - ui_check_module_datanames(ns("shiny_warnings")) - ), - div( - class = "teal_validated", - uiOutput(ns("previous_failed")) - ) - ) -} - -#' @rdname module_teal_data -srv_validate_reactive_teal_data <- function(id, # nolint: object_length - data, - modules = NULL, - validate_shiny_silent_error = FALSE, - hide_validation_error = reactive(FALSE)) { - checkmate::assert_string(id) - checkmate::assert_multi_class(modules, c("teal_modules", "teal_module"), null.ok = TRUE) - checkmate::assert_flag(validate_shiny_silent_error) - - moduleServer(id, function(input, output, session) { - # there is an empty reactive cycle on `init` and `data` has `shiny.silent.error` class - srv_validate_error("silent_error", data, validate_shiny_silent_error) - srv_check_class_teal_data("class_teal_data", data) - srv_check_module_datanames("shiny_warnings", data, modules) - output$previous_failed <- renderUI({ - if (hide_validation_error()) { - shinyjs::hide("validate_messages") - tags$div("One of previous transformators failed. Please check its inputs.", class = "teal-output-warning") - } else { - shinyjs::show("validate_messages") - NULL - } - }) - - .trigger_on_success(data) - }) -} - -#' @keywords internal -ui_validate_error <- function(id) { - ns <- NS(id) - uiOutput(ns("message")) -} - -#' @keywords internal -srv_validate_error <- function(id, data, validate_shiny_silent_error) { - checkmate::assert_string(id) - checkmate::assert_flag(validate_shiny_silent_error) - moduleServer(id, function(input, output, session) { - output$message <- renderUI({ - is_shiny_silent_error <- inherits(data(), "shiny.silent.error") && identical(data()$message, "") - if (inherits(data(), "qenv.error")) { - validate( - need( - FALSE, - paste( - "Error when executing the `data` module:", - cli::ansi_strip(paste(data()$message, collapse = "\n")), - "\nCheck your inputs or contact app developer if error persists.", - collapse = "\n" - ) - ) - ) - } else if (inherits(data(), "error")) { - if (is_shiny_silent_error && !validate_shiny_silent_error) { - return(NULL) - } - validate( - need( - FALSE, - sprintf( - "Shiny error when executing the `data` module.\n%s\n%s", - data()$message, - "Check your inputs or contact app developer if error persists." - ) - ) - ) - } - }) - }) -} - - -#' @keywords internal -ui_check_class_teal_data <- function(id) { - ns <- NS(id) - uiOutput(ns("message")) -} - -#' @keywords internal -srv_check_class_teal_data <- function(id, data) { - checkmate::assert_string(id) - moduleServer(id, function(input, output, session) { - output$message <- renderUI({ - validate( - need( - inherits(data(), c("teal_data", "error")), - "Did not receive `teal_data` object. Cannot proceed further." - ) - ) - }) - }) -} - -#' @keywords internal -ui_check_module_datanames <- function(id) { - ns <- NS(id) - uiOutput(NS(id, "message")) -} - -#' @keywords internal -srv_check_module_datanames <- function(id, data, modules) { - checkmate::assert_string(id) - moduleServer(id, function(input, output, session) { - output$message <- renderUI({ - if (inherits(data(), "teal_data")) { - is_modules_ok <- check_modules_datanames_html( - modules = modules, datanames = names(data()) - ) - if (!isTRUE(is_modules_ok)) { - tags$div(is_modules_ok, class = "teal-output-warning") - } - } - }) - }) -} - -.trigger_on_success <- function(data) { - out <- reactiveVal(NULL) - observeEvent(data(), { - if (inherits(data(), "teal_data")) { - if (!identical(data(), out())) { - out(data()) - } - } - }) - - out -} diff --git a/R/module_transform_data.R b/R/module_transform_data.R index 26b49f0b4..63f36c26b 100644 --- a/R/module_transform_data.R +++ b/R/module_transform_data.R @@ -3,7 +3,13 @@ #' Module calls [teal_transform_module()] in sequence so that `reactive teal_data` output #' from one module is handed over to the following module's input. #' -#' @inheritParams module_teal_data +#' @inheritParams module_teal_module +#' @param data_module (`teal_data_module`) +#' @param modules (`teal_modules` or `teal_module`) For `datanames` validation purpose +#' @param validate_shiny_silent_error (`logical`) If `TRUE`, then `shiny.silent.error` is validated and +#' @param is_transform_failed (`reactiveValues`) contains `logical` flags named after each transformator. +#' Help to determine if any previous transformator failed, so that following transformators can be disabled +#' and display a generic failure message. #' @inheritParams teal_modules #' @param class (character(1)) CSS class to be added in the `div` wrapper tag. @@ -31,31 +37,31 @@ ui_transform_teal_data <- function(id, transformators, class = "well") { child_id <- NS(id, name) ns <- NS(child_id) data_mod <- transformators[[name]] - transform_wrapper_id <- ns(sprintf("wrapper_%s", name)) - - display_fun <- if (is.null(data_mod$ui)) shinyjs::hidden else function(x) x - - display_fun( - bslib::accordion( - bslib::accordion_panel( - attr(data_mod, "label"), - icon = bsicons::bs_icon("palette-fill"), - tags$div( - id = transform_wrapper_id, - if (is.null(data_mod$ui)) { - return(NULL) - } else { - data_mod$ui(id = ns("transform")) - }, - div( - id = ns("validate_messages"), - class = "teal_validated", - uiOutput(ns("error_wrapper")) - ) - ) + + body_ui <- if (is.null(data_mod$ui)) NULL else data_mod$ui(id = ns("transform")) + + result <- bslib::accordion( + id = ns("wrapper"), + class = "validation-wrapper", + bslib::accordion_panel( + attr(data_mod, "label", exact = TRUE), + icon = bsicons::bs_icon("palette-fill"), + tags$div( + class = "disabled-info", + title = "Disabled until data becomes valid", + bsicons::bs_icon("info-circle"), + "Disabled until data becomes valid. Check your inputs." + ), + tags$div( + id = ns(sprintf("wrapper_%s", name)), + ui_module_validate(ns("validation")), + body_ui ) ) ) + + if (is.null(body_ui)) result <- shinyjs::hidden(result) + result } ) } @@ -80,8 +86,8 @@ srv_transform_teal_data <- function(id, data, transformators, modules = NULL, is function(data_previous, name) { moduleServer(name, function(input, output, session) { logger::log_debug("srv_transform_teal_data@1 initializing module for { name }.") - data_out <- reactiveVal() + .call_once_when(inherits(data_previous(), "teal_data"), { logger::log_debug("srv_teal_transform_teal_data@2 triggering a transform module call for { name }.") data_unhandled <- transformators[[name]]$server("transform", data = data_previous) @@ -111,11 +117,13 @@ srv_transform_teal_data <- function(id, data, transformators, modules = NULL, is any(idx_failures < idx_this) }) - srv_validate_error("silent_error", data_handled, validate_shiny_silent_error = FALSE) - srv_check_class_teal_data("class_teal_data", data_handled) - if (!is.null(modules)) { - srv_check_module_datanames("datanames_warning", data_handled, modules) - } + srv_module_validate_transform( + "validation", + x = data_handled, + validate_shiny_silent_error = FALSE, + show_warn = is_previous_failed, + message_warn = "One of the previous transformators failed. Please check its inputs." + ) # When there is no UI (`ui = NULL`) it should still show the errors observe({ @@ -123,30 +131,14 @@ srv_transform_teal_data <- function(id, data, transformators, modules = NULL, is shinyjs::show("wrapper") } }) - - transform_wrapper_id <- sprintf("wrapper_%s", name) - output$error_wrapper <- renderUI({ - if (is_previous_failed()) { - shinyjs::disable(transform_wrapper_id) - tags$div( - "One of previous transformators failed. Please check its inputs.", - class = "teal-output-warning" - ) - } else { - shinyjs::enable(transform_wrapper_id) - shiny::tagList( - ui_validate_error(session$ns("silent_error")), - ui_check_class_teal_data(session$ns("class_teal_data")), - ui_check_module_datanames(session$ns("datanames_warning")) - ) - } + # Disable the UI elements in case of previous error + observe({ + shinyjs::toggleState(sprintf("wrapper_%s", name), condition = !is_previous_failed()) }) }) # Ignoring unwanted reactivity breaks during initialization - reactive({ - req(data_out()) - }) + reactive(req(data_out())) }) }, x = names(transformators), diff --git a/R/module_validate.R b/R/module_validate.R new file mode 100644 index 000000000..813071a2f --- /dev/null +++ b/R/module_validate.R @@ -0,0 +1,284 @@ +#' Factory to build validate module server function +#' +#' Create a module that validates the reactive data. +#' It dynamically generates a `server` function that can be use internally in teal +#' or in a teal module. The `ui` function is generic and common to all modules. +#' +#' @param ... (`function`) 1 or more [`shiny::moduleServer()`] functions that +#' return a [`shiny::reactive()`] with `TRUE` or a character string detailing the exception. +#' It can be a named function, a character string or an anonymous function. +#' @param stop_on_first (`logical(1)`) If `TRUE` (default), only shows the first error. +#' +#' @returns A `server` function with code generated from the function supplied in the arguments. +#' @examples +#' check_error <- function(x, skip_on_empty_message = TRUE) { +#' moduleServer("check_error", function(input, output, session) { +#' reactive({ +#' if (inherits(x(), "error") && (!skip_on_empty_message || !identical(x()$message, ""))) { +#' tagList(tags$strong("Error detected"), tags$blockquote(x()$message)) +#' } else { +#' TRUE +#' } +#' }) +#' }) +#' } +#' srv_module_validate_factory(check_error) +#' +#' check_numeric <- function(x, null.ok = FALSE) { +#' moduleServer("check_numeric", function(input, output, session) { +#' reactive(checkmate::check_numeric(x(), null.ok = null.ok)) +#' }) +#' } +#' srv_module_validate_factory(check_error, check_numeric) +#' @keywords internal +srv_module_validate_factory <- function(..., stop_on_first = TRUE) { + dots <- rlang::list2(...) + checkmate::check_list(dots, min.len = 1) + checkmate::assert_flag(stop_on_first) + + fun_names <- match.call(expand.dots = FALSE)[["..."]] # Capture function names in arguments + check_calls <- lapply( # Generate calls to each of the check functions + seq_along(dots), + function(fun_ix) { + substitute( + collection <- append(collection, check_call), + list(check_call = rlang::call2(fun_names[[fun_ix]], !!!lapply(names(formals(dots[[fun_ix]])), as.name))) + ) + } + ) + + new_server_fun <- function(id) TRUE # Empty server template + server_formals <- .join_formals(formals(new_server_fun), dots) + server_body <- .validate_module_server(check_calls, stop_on_first = stop_on_first) + formals(new_server_fun) <- server_formals # update function formals + body(new_server_fun) <- server_body # set the new generated body + new_server_fun +} + +#' @rdname srv_module_validate_factory +ui_module_validate <- function(id) { + div( + id = NS(id, "validate_messages"), + class = "teal_validated", + tags$div(class = "messages", uiOutput(NS(id, "errors"))) + ) +} + +#' @keywords internal +.validate_module_server <- function(check_calls, stop_on_first) { + condition <- if (stop_on_first) { + quote(length(u) > 0 || isTRUE(v()) || is.null(v())) + } else { + quote(isTRUE(v()) || is.null(v())) + } + module_server_body <- bquote( + { # Template moduleServer that supports multiple checks + checkmate::assert_string(id) # Mandatory id parameter + top_level_x <- x + moduleServer(id, function(input, output, session) { + x <- reactive(tryCatch(top_level_x(), error = function(e) e)) + collection <- list() + ..(check_calls) # Generates expressions: "collection <- append(collection, srv_module_check_xxxx(x))" + + fun <- function(u, v) if (.(condition)) u else append(u, list(v())) + validate_r <- reactive(Reduce(fun, x = collection, init = list())) + has_errors <- reactiveVal(TRUE) + + output$errors <- renderUI({ + error_class <- c("shiny.silent.error", "validation", "error", "condition") + if (length(validate_r()) > 0) { + has_errors(FALSE) + tagList(!!!lapply(validate_r(), .render_output_condition)) + } else { + has_errors(TRUE) + NULL + } + }) + has_errors + }) + }, + splice = TRUE + ) +} + +#' @keywords internal +.render_output_condition <- function(cond) { + checkmate::assert_multi_class(cond, c("shiny.tag", "shiny.tag.list", "character")) + is_warning <- isTRUE(attr(cond[1], "is_warning")) || isTRUE(attr(cond, "is_warning")) + + html_class <- sprintf( + "teal-output-condition %s", + ifelse(is_warning, "teal-output-warning", "shiny-output-error") + ) + + if (checkmate::test_character(cond)) { + html_class <- c(html_class, "prewrap-ws") + cond <- lapply(cond, tags$p) + } + tags$div(class = html_class, tags$div(cond)) +} + +#' @keywords internal +.join_formals <- function(current_formals, call_list) { + checkmate::assert( + checkmate::check_list(current_formals), + checkmate::check_class(current_formals, "pairlist") + ) + Reduce( # Union of formals for all check functions (order of arguments is kept) + function(u, v) { + new_formals <- formals(v) + vapply(intersect(names(new_formals), names(u)), function(x_name) { + identical(new_formals[[x_name]], u[[x_name]]) || # Conflicting name/default pair will throw an exception. + stop("Arguments for check function have conflicting definitions (different defaults)") + }, FUN.VALUE = logical(1L)) + append(u, new_formals[setdiff(names(new_formals), names(u))]) + }, + init = current_formals, + x = call_list + ) +} + +#' @keywords internal +srv_module_check_datanames <- function(x, modules) { + moduleServer("check_datanames", function(input, output, session) { + reactive({ + if (!is.null(modules) && inherits(x(), "teal_data")) { + is_modules_ok <- check_modules_datanames_html( + modules = modules, datanames = names(x()) + ) + attr(is_modules_ok, "is_warning") <- TRUE + is_modules_ok + } else { + TRUE + } + }) + }) +} + +#' @keywords internal +srv_module_check_reactive <- function(x, null.ok = FALSE) { # nolint: object_name_linter. + reactive_message <- check_reactive(x, null.ok = null.ok) + moduleServer("check_reactive", function(input, output, session) { + reactive(if (isTRUE(reactive_message)) reactive_message else TRUE) + }) +} + +#' @keywords internal +srv_module_check_validation <- function(x) { + moduleServer("check_validation_error", function(input, output, session) { + reactive({ + if (checkmate::test_class(x(), c("shiny.silent.error", "validation")) && !identical(x()$message, "")) { + tagList( + tags$span("Validation error:"), + tags$blockquote(tags$em(x()$message)) + ) + } else { + TRUE + } + }) + }) +} + +#' @keywords internal +srv_module_check_shinysilenterror <- function(x, validate_shiny_silent_error = TRUE) { # nolint: object_length. + moduleServer("check_shinysilenterror", function(input, output, session) { + reactive({ + if (validate_shiny_silent_error && inherits(x(), "shiny.silent.error" && !identical(x()$message, ""))) { + "Shiny silent error was raised" + } else { + TRUE + } + }) + }) +} + +#' @keywords internal +srv_module_check_teal_data <- function(x) { + moduleServer("check_teal_data", function(input, output, session) { + reactive({ + if (inherits(x(), "qenv.error")) { # TODO: remove qenv.error + details <- attr(x(), "details", exact = TRUE) + if (is.null(details)) { + c( + "Error when executing the `data` module:", + cli::ansi_strip(x()$message), + "", + "Check your inputs or contact app developer if error persists." + ) + } else { + tagList( + tags$span("Error when executing the", tags$code("data"), "module:"), + tags$blockquote(tags$em(cli::ansi_strip(details$condition_message))), + tags$span("from code:"), + tags$code(class = "code-error", details$current_code) + ) + } + } else if (!inherits(x(), c("teal_data", "error"))) { + tags$span( + "Did not receive", tags$code("teal_data"), "object. Cannot proceed further." + ) + } else { + TRUE + } + }) + }) +} + +#' @keywords internal +srv_module_check_condition <- function(x) { + moduleServer("check_error", function(input, output, session) { + reactive({ # shiny.silent.errors are handled in a different module + if (inherits(x(), "error") && !inherits(x(), "shiny.silent.error")) { + tagList( + tags$span("Error detected:"), + tags$blockquote(tags$em(trimws(x()$message))) + ) + } else { + TRUE + } + }) + }) +} + +#' @keywords internal +srv_module_check_previous_state_warn <- function(x, show_warn = reactive(FALSE), message_warn = "not defined") { # nolint: object_length,line_length. + assert_reactive(show_warn) + checkmate::assert( + checkmate::check_string(message_warn), + checkmate::check_class(message_warn, "shiny.tag"), + checkmate::check_class(message_warn, "shiny.tag.list") + ) + + attr(message_warn, "is_warning") <- TRUE + moduleServer("check_shinysilenterror", function(input, output, session) { + reactive(if (show_warn()) message_warn else TRUE) + }) +} + +srv_module_validate_teal_module <- srv_module_validate_factory( # nolint: object_length. + srv_module_check_previous_state_warn, + srv_module_check_shinysilenterror, + srv_module_check_validation, + srv_module_check_condition, + srv_module_check_reactive, + srv_module_check_teal_data, + srv_module_check_datanames +) + +srv_module_validate_transform <- srv_module_validate_factory( + srv_module_check_previous_state_warn, + srv_module_check_shinysilenterror, + srv_module_check_validation, + srv_module_check_reactive, + srv_module_check_teal_data, + srv_module_check_condition +) + +srv_module_validate_datanames <- srv_module_validate_factory( + srv_module_check_previous_state_warn, + srv_module_check_datanames +) + +srv_module_validate_validation <- srv_module_validate_factory( + srv_module_check_validation +) diff --git a/R/teal_data_module.R b/R/teal_data_module.R index 64ed9d9cb..159571b37 100644 --- a/R/teal_data_module.R +++ b/R/teal_data_module.R @@ -16,6 +16,21 @@ #' #' See vignette `vignette("data-as-shiny-module", package = "teal")` for more details. #' +#' @section data validation: +#' +#' Executed [teal_data_module()] is validated and output is validated for consistency. +#' Output `data` is invalid if: +#' 1. [teal_data_module()] is invalid if server doesn't return `reactive`. **Immediately crashes an app!** +#' 2. `reactive` throws a `shiny.error` - happens when module creating [teal.data::teal_data()] fails. +#' 3. `reactive` returns `qenv.error` - happens when [teal.data::teal_data()] evaluates a failing code. +#' 4. `reactive` object doesn't return [teal.data::teal_data()]. +#' 5. [teal.data::teal_data()] object lacks any `datanames` specified in the `modules` argument. +#' +#' `teal` (observers in `srv_teal`) always waits to render an app until `reactive` `teal_data` is +#' returned. If error 2-4 occurs, relevant error message is displayed to the app user. Once the issue is +#' resolved, the app will continue to run. `teal` guarantees that errors in data don't crash the app +#' (except error 1). +#' #' @param ui (`function(id)`) #' `shiny` module UI function; must only take `id` argument #' @param server (`function(id)`) diff --git a/R/teal_transform_module.R b/R/teal_transform_module.R index 71ba28ca1..6cebd6315 100644 --- a/R/teal_transform_module.R +++ b/R/teal_transform_module.R @@ -146,7 +146,6 @@ teal_transform_module <- function(ui = NULL, ) } - decorate_err_msg( assert_reactive(data_out), pre = sprintf("From: 'teal_transform_module()':\nA 'teal_transform_module' with \"%s\" label:", label), diff --git a/R/utils.R b/R/utils.R index 6dd53dc11..c21846029 100644 --- a/R/utils.R +++ b/R/utils.R @@ -175,7 +175,7 @@ check_modules_datanames_html <- function(modules, datanames) { lapply( check_datanames, function(mod) { - tagList( + tags$p( tags$span( tags$span(pluralize(mod$missing_datanames, "Dataset")), to_html_code_list(mod$missing_datanames), diff --git a/inst/css/validation.css b/inst/css/validation.css index 665fac2d7..ef792c47e 100644 --- a/inst/css/validation.css +++ b/inst/css/validation.css @@ -1,49 +1,110 @@ -/* adding boarder to the validated input */ -.teal_validated:has(.shiny-output-error) { - border: 1px solid red; - border-radius: 4px; +.teal_validated:has(.shiny-output-error, .teal-output-warning) { padding: 1em; } +.sidebar .teal_validated { padding: 0.2em; } + +.validation-wrapper { + --bs-accordion-body-padding-y: 0 1rem; +} + +.validation-wrapper[disabled="disabled"] { + --bs-accordion-bg: var(--bs-gray-100); + background-color: var(--bs-gray-100); +} + +.validation-wrapper[disabled="disabled"]:has(.shiny-output-error, .teal-output-warning) .disabled-info, +.validation-wrapper .disabled-info { + display: none; } -.teal_validated:has(.teal-output-warning) { - border: 1px solid orange; +.validation-wrapper[disabled="disabled"] .disabled-info { + display: block; + border-color: var(--bs-info); + padding: 1em; + color: color-mix(in srgb, var(--bs-info), black 20%); + background-color: color-mix(in srgb, var(--bs-info) 5%, transparent); +} + +.teal_validated:has(.shiny-output-error), +.teal_validated:has(.teal-output-warning), +.teal_validated:has(.teal-output-warning-previous), +.validation-wrapper .disabled-info{ + border: solid 1px transparent; border-radius: 4px; } -.teal_validated .teal-output-warning { - color: #888; +.teal_validated:has(.shiny-output-error) { + color: color-mix(in srgb, var(--bs-danger), black 20%); + border-color: var(--bs-danger); + background-color: color-mix(in srgb, var(--bs-danger) 5%, transparent); +} + +.teal_validated:has(.teal-output-warning), +.teal_validated.previous-failed:has(.teal-output-warning-previous) { + color: color-mix(in srgb, var(--bs-warning), black 50%); + border-color: var(--bs-warning) + background-color: color-mix(in srgb, var(--bs-warning) 5%, transparent); +} + +.validation-wrapper .accordion-item:has(.teal_validated.previous-failed) { + background-color: var(--bs-gray-100); + color: var(--bs-gray-600) } +.teal-output-condition { white-space: normal; } +.teal-output-condition .prewrap-ws { white-space: pre-wrap; } + .teal_validated .shiny-output-error, -.teal_validated .teal-output-warning { - margin-top: 0.5em; +.teal_validated .teal-output-warning, +.teal_validated.previous-failed .teal-output-warning-previous { + display: flex; + margin: 0 0 0 0; +} + +.sidebar .teal_validated .shiny-output-error, +.sidebar .teal_validated .teal-output-warning, +.sidebar .teal_validated.previous-failed .teal-output-warning-previous { + display: flex; + margin: 0.2em 0 0.2em 0; +} + +.teal_validated .shiny-output-error > div, +.teal_validated .teal-output-warning > div, +.teal_validated.previous-failed .teal-output-warning-previous > div { + display: flex; + flex-direction: column; + width: 100%; } -.teal_validated .teal-output-warning::before { +.teal_validated .teal-output-warning::before, +.teal_validated .teal-output-warning-previous::before { content: "\26A0\FE0F"; + padding: 0 0.3em 0 0.3em; } .teal_validated .shiny-output-error::before { content: "\1F6A8"; + padding: 0 0.3em 0 0.3em; } -.teal_primary_col .shiny-output-error::before { - content: "\1F6A8"; -} +.code-error { margin-left: 1em; } +.sidebar .code-error { margin-left: 0.3em; } -.teal_primary_col .teal-output-warning::before { - content: "\26A0\FE0F"; +.teal-sidebar .teal-output-condition blockcode, +.teal-sidebar .teal-output-condition .code-error { + padding-left: 0; + margin-left: 0; } -.teal_primary_col .teal_validated:has(.shiny-output-error), -.teal_primary_col .teal_validated:has(.teal-output-warning) { - margin: 1em 0 1em 0; - padding: 0.5em 0 0.5em 0.5em; +.teal_validated p { margin-bottom: 0; } + +.teal_validated.previous-failed .messages, +.teal_validated .teal-output-warning-previous { + display: none; } -.teal_primary_col > .teal_validated:has(.teal-output-warning), -.teal_primary_col > .teal_validated:has(.shiny-output-error) { - width: 100%; - background-color: rgba(223, 70, 97, 0.1); - border: 1px solid red; - padding: 1em; +.teal_validated.previous-failed .teal-output-warning-previous { display: flex; } + +.blurred { filter: blur(5px); } +.validation-wrapper:has(.shiny-output-error) { + border: 3px solid var(--bs-danger); + border-radius: var(--bs-accordion-border-radius); } diff --git a/man/module_init_data.Rd b/man/module_init_data.Rd index 1671322bb..e894d9372 100644 --- a/man/module_init_data.Rd +++ b/man/module_init_data.Rd @@ -46,7 +46,7 @@ methods (1 and 2) produce the same reactive behavior within a \code{teal} applic lies in data control: the first method involves external control, while the second method involves control from a custom module within the app. -For more details, see \code{\link{module_teal_data}}. +For more details, see \code{\link{teal_data_module}}. } } \keyword{internal} diff --git a/man/module_teal_data.Rd b/man/module_teal_data.Rd deleted file mode 100644 index ae54af938..000000000 --- a/man/module_teal_data.Rd +++ /dev/null @@ -1,81 +0,0 @@ -% Generated by roxygen2: do not edit by hand -% Please edit documentation in R/module_teal_data.R -\name{module_teal_data} -\alias{module_teal_data} -\alias{ui_teal_data_module} -\alias{ui_teal_data} -\alias{srv_teal_data_module} -\alias{srv_teal_data} -\alias{ui_validate_reactive_teal_data} -\alias{srv_validate_reactive_teal_data} -\title{Execute and validate \code{teal_data_module}} -\usage{ -ui_teal_data_module(id, data_module = function(id) NULL) - -srv_teal_data_module( - id, - data_module = function(id) NULL, - modules = NULL, - validate_shiny_silent_error = TRUE, - is_transform_failed = reactiveValues() -) - -ui_validate_reactive_teal_data(id) - -srv_validate_reactive_teal_data( - id, - data, - modules = NULL, - validate_shiny_silent_error = FALSE, - hide_validation_error = reactive(FALSE) -) -} -\arguments{ -\item{id}{(\code{character(1)}) \code{shiny} module instance id.} - -\item{data_module}{(\code{teal_data_module})} - -\item{modules}{(\code{teal_modules} or \code{teal_module}) For \code{datanames} validation purpose} - -\item{validate_shiny_silent_error}{(\code{logical}) If \code{TRUE}, then \code{shiny.silent.error} is validated and} - -\item{is_transform_failed}{(\code{reactiveValues}) contains \code{logical} flags named after each transformator. -Help to determine if any previous transformator failed, so that following transformators can be disabled -and display a generic failure message.} - -\item{data}{(\code{reactive} returning \code{teal_data})} -} -\value{ -\code{reactive} \code{teal_data} -} -\description{ -This is a low level module to handle \code{teal_data_module} execution and validation. -\code{\link[=teal_transform_module]{teal_transform_module()}} inherits from \code{\link[=teal_data_module]{teal_data_module()}} so it is handled by this module too. -\code{\link[=srv_teal]{srv_teal()}} accepts various \code{data} objects and eventually they are all transformed to \code{reactive} -\code{\link[teal.data:teal_data]{teal.data::teal_data()}} which is a standard data class in whole \code{teal} framework. -} -\note{ -\code{ui_teal_data_module} was renamed from \code{ui_teal_data}. - -\code{srv_teal_data_module} was renamed from \code{srv_teal_data}. -} -\section{data validation}{ - - -Executed \code{\link[=teal_data_module]{teal_data_module()}} is validated and output is validated for consistency. -Output \code{data} is invalid if: -\enumerate{ -\item \code{\link[=teal_data_module]{teal_data_module()}} is invalid if server doesn't return \code{reactive}. \strong{Immediately crashes an app!} -\item \code{reactive} throws a \code{shiny.error} - happens when module creating \code{\link[teal.data:teal_data]{teal.data::teal_data()}} fails. -\item \code{reactive} returns \code{qenv.error} - happens when \code{\link[teal.data:teal_data]{teal.data::teal_data()}} evaluates a failing code. -\item \code{reactive} object doesn't return \code{\link[teal.data:teal_data]{teal.data::teal_data()}}. -\item \code{\link[teal.data:teal_data]{teal.data::teal_data()}} object lacks any \code{datanames} specified in the \code{modules} argument. -} - -\code{teal} (observers in \code{srv_teal}) always waits to render an app until \code{reactive} \code{teal_data} is -returned. If error 2-4 occurs, relevant error message is displayed to the app user. Once the issue is -resolved, the app will continue to run. \code{teal} guarantees that errors in data don't crash the app -(except error 1). -} - -\keyword{internal} diff --git a/man/module_transform_data.Rd b/man/module_transform_data.Rd index fca127340..2d67a2d6a 100644 --- a/man/module_transform_data.Rd +++ b/man/module_transform_data.Rd @@ -31,6 +31,10 @@ To learn more check \code{vignette("transform-input-data", package = "teal")}.} \item{is_transform_failed}{(\code{reactiveValues}) contains \code{logical} flags named after each transformator. Help to determine if any previous transformator failed, so that following transformators can be disabled and display a generic failure message.} + +\item{data_module}{(\code{teal_data_module})} + +\item{validate_shiny_silent_error}{(\code{logical}) If \code{TRUE}, then \code{shiny.silent.error} is validated and} } \value{ \code{reactive} \code{teal_data} diff --git a/man/srv_module_validate_factory.Rd b/man/srv_module_validate_factory.Rd new file mode 100644 index 000000000..495a95845 --- /dev/null +++ b/man/srv_module_validate_factory.Rd @@ -0,0 +1,48 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/module_validate.R +\name{srv_module_validate_factory} +\alias{srv_module_validate_factory} +\alias{ui_module_validate} +\title{Factory to build validate module server function} +\usage{ +srv_module_validate_factory(..., stop_on_first = TRUE) + +ui_module_validate(id) +} +\arguments{ +\item{...}{(\code{function}) 1 or more \code{\link[shiny:moduleServer]{shiny::moduleServer()}} functions that +return a \code{\link[shiny:reactive]{shiny::reactive()}} with \code{TRUE} or a character string detailing the exception. +It can be a named function, a character string or an anonymous function.} + +\item{stop_on_first}{(\code{logical(1)}) If \code{TRUE} (default), only shows the first error.} +} +\value{ +A \code{server} function with code generated from the function supplied in the arguments. +} +\description{ +Create a module that validates the reactive data. +It dynamically generates a \code{server} function that can be use internally in teal +or in a teal module. The \code{ui} function is generic and common to all modules. +} +\examples{ +check_error <- function(x, skip_on_empty_message = TRUE) { + moduleServer("check_error", function(input, output, session) { + reactive({ + if (inherits(x(), "error") && (!skip_on_empty_message || !identical(x()$message, ""))) { + tagList(tags$strong("Error detected"), tags$blockquote(x()$message)) + } else { + TRUE + } + }) + }) +} +srv_module_validate_factory(check_error) + +check_numeric <- function(x, null.ok = FALSE) { + moduleServer("check_numeric", function(input, output, session) { + reactive(checkmate::check_numeric(x(), null.ok = null.ok)) + }) +} +srv_module_validate_factory(check_error, check_numeric) +} +\keyword{internal} diff --git a/man/teal_data_module.Rd b/man/teal_data_module.Rd index 683c6d9ef..cc66d1456 100644 --- a/man/teal_data_module.Rd +++ b/man/teal_data_module.Rd @@ -76,6 +76,25 @@ The code is added to the \verb{@code} slot of the \code{teal_data}. It accepts only inline expressions (both simple and compound) and allows for injecting values into \code{expr} through the \code{...} argument: as \code{name:value} pairs are passed to \code{...}, \code{name} in \code{expr} will be replaced with \code{value.} } +\section{data validation}{ + + +Executed \code{\link[=teal_data_module]{teal_data_module()}} is validated and output is validated for consistency. +Output \code{data} is invalid if: +\enumerate{ +\item \code{\link[=teal_data_module]{teal_data_module()}} is invalid if server doesn't return \code{reactive}. \strong{Immediately crashes an app!} +\item \code{reactive} throws a \code{shiny.error} - happens when module creating \code{\link[teal.data:teal_data]{teal.data::teal_data()}} fails. +\item \code{reactive} returns \code{qenv.error} - happens when \code{\link[teal.data:teal_data]{teal.data::teal_data()}} evaluates a failing code. +\item \code{reactive} object doesn't return \code{\link[teal.data:teal_data]{teal.data::teal_data()}}. +\item \code{\link[teal.data:teal_data]{teal.data::teal_data()}} object lacks any \code{datanames} specified in the \code{modules} argument. +} + +\code{teal} (observers in \code{srv_teal}) always waits to render an app until \code{reactive} \code{teal_data} is +returned. If error 2-4 occurs, relevant error message is displayed to the app user. Once the issue is +resolved, the app will continue to run. \code{teal} guarantees that errors in data don't crash the app +(except error 1). +} + \examples{ tdm <- teal_data_module( ui = function(id) { diff --git a/teal.Rproj b/teal.Rproj index ab99014ab..dc9baae32 100644 --- a/teal.Rproj +++ b/teal.Rproj @@ -1,4 +1,5 @@ Version: 1.0 +ProjectId: e5118e65-0c4b-46e6-ac30-08a55e0c5e8c RestoreWorkspace: Default SaveWorkspace: Default