diff --git a/R/module_teal.R b/R/module_teal.R index e34cc3fa29..05294cc950 100644 --- a/R/module_teal.R +++ b/R/module_teal.R @@ -107,6 +107,7 @@ ui_teal <- function(id, modules) { include_teal_css_js(), shinyjs::useShinyjs(), shiny::includeScript(system.file("js/extendShinyJs.js", package = "teal")), + shiny::singleton(shiny::includeScript(system.file("js/input-validator.js", package = "teal"))), shiny_busy_message_panel, tags$div(id = ns("tabpanel_wrapper"), class = "teal-body", navbar), tags$hr(style = "margin: 1rem 0 0.5rem 0;") diff --git a/R/validate_inputs.R b/R/validate_inputs.R index dc18f4490c..ec2d2e7e8d 100644 --- a/R/validate_inputs.R +++ b/R/validate_inputs.R @@ -193,3 +193,45 @@ any_names <- function(x) { } ) } + +#' Validate input +#' +#' @param inputId (`character`) Character of input ID(s) to validate +#' @param condition (`logical(1)`, `function(x)`) Logical value or function returning logical value. +#' Condition should determine expected state, `FALSE` throws. +#' @param message (`character(1)`) Character string of validation message to display +#' @param session Shiny session object +#' +#' @return `NULL` or `shiny.silent.error` when condition is not met +#' +#' @keywords internal +validate_input <- function(inputId, # nolint + condition = function(x) TRUE, + message = "", + session = shiny::getDefaultReactiveDomain()) { + checkmate::assert_character(inputId, min.len = 1) + checkmate::assert( + checkmate::check_flag(condition), + checkmate::check_function(condition, nargs = length(inputId)) + ) + checkmate::assert_string(message) + + # Evaluate condition if it's a function + condition_result <- if (is.function(condition)) { + input_value <- lapply(inputId, function(id) session$input[[id]]) + checkmate::assert_flag(do.call(condition, input_value)) + } else { + condition + } + + # Send custom message to JavaScript handler + lapply(inputId, function(id) { + session$sendCustomMessage("validateInput", list( + inputId = session$ns(id), + isValid = condition_result, + message = message + )) + }) + + validate(need(condition_result, message)) +} diff --git a/inst/css/validation.css b/inst/css/validation.css index 665fac2d73..9b96617db2 100644 --- a/inst/css/validation.css +++ b/inst/css/validation.css @@ -40,10 +40,10 @@ padding: 0.5em 0 0.5em 0.5em; } -.teal_primary_col > .teal_validated:has(.teal-output-warning), -.teal_primary_col > .teal_validated:has(.shiny-output-error) { +.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; -} +} \ No newline at end of file diff --git a/inst/js/extendShinyJs.js b/inst/js/extendShinyJs.js deleted file mode 100644 index 0c1f9f91f1..0000000000 --- a/inst/js/extendShinyJs.js +++ /dev/null @@ -1,22 +0,0 @@ -// This file contains functions that should be executed at the start of each session, -// not included in the original HTML - -shinyjs.autoFocusModal = function(id) { - document.getElementById('shiny-modal').addEventListener( - 'shown.bs.modal', - () => document.getElementById(id).focus(), - { once: true } - ); -} - -shinyjs.enterToSubmit = function(id, submit_id) { - document.getElementById('shiny-modal').addEventListener( - 'shown.bs.modal', - () => document.getElementById(id).addEventListener('keyup', (e) => { - if (e.key === 'Enter') { - e.preventDefault(); // prevent form submission - document.getElementById(submit_id).click(); - } - }) - ); -} diff --git a/inst/js/input-validator.js b/inst/js/input-validator.js new file mode 100644 index 0000000000..0a9760d278 --- /dev/null +++ b/inst/js/input-validator.js @@ -0,0 +1,29 @@ +$(document).on('shiny:connected', function () { + Shiny.addCustomMessageHandler('validateInput', function (data) { + var inputId = data.inputId; + var isValid = data.isValid; + var message = data.message; + + // Try both CSS selector patterns + var selector1 = '.shiny-input-container#' + inputId; + var selector2 = '.shiny-input-container:has(#' + inputId + ')'; + + var container = $(selector1); + if (container.length === 0) { + container = $(selector2); + } + + if (container.length > 0) { + // Remove existing validation message + container.find('.shiny-output-error').remove(); + + // Add validation message if rule failed + if (!isValid && message && message.trim() !== '') { + var validationSpan = $('').addClass('shiny-output-error').text(message); + container.append(validationSpan); + } + } else { + console.warn('Container not found for input: ' + inputId); + } + }); +}); \ No newline at end of file diff --git a/man/validate_input.Rd b/man/validate_input.Rd new file mode 100644 index 0000000000..bb7b5e9dde --- /dev/null +++ b/man/validate_input.Rd @@ -0,0 +1,30 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/validate_inputs.R +\name{validate_input} +\alias{validate_input} +\title{Validate input} +\usage{ +validate_input( + inputId, + condition = function(x) TRUE, + message = "", + session = shiny::getDefaultReactiveDomain() +) +} +\arguments{ +\item{inputId}{(\code{character}) Character of input ID(s) to validate} + +\item{condition}{(\code{logical(1)}, \verb{function(x)}) Logical value or function returning logical value. +Condition should determine expected state, \code{FALSE} throws.} + +\item{message}{(\code{character(1)}) Character string of validation message to display} + +\item{session}{Shiny session object} +} +\value{ +\code{NULL} or \code{shiny.silent.error} when condition is not met +} +\description{ +Validate input +} +\keyword{internal} diff --git a/tests/testthat/test-shinytest2-validate_input.R b/tests/testthat/test-shinytest2-validate_input.R new file mode 100644 index 0000000000..6bba8d18c9 --- /dev/null +++ b/tests/testthat/test-shinytest2-validate_input.R @@ -0,0 +1,124 @@ +testthat::skip_if_not_installed("shinytest2") +testthat::skip_if_not_installed("rvest") + +testthat::test_that("e2e: validate_input displays validation message when input is invalid, no message when valid", { + skip_if_too_deep(5) + + validation_module <- function(label = "validation test") { + module( + label = label, + server = function(id, data) { + moduleServer(id, function(input, output, session) { + output$plot <- renderPlot({ + teal:::validate_input( + inputId = "number", + condition = function(x) !is.null(x) && as.numeric(x) > 5, + message = "Please select a number greater than 5", + session = session + ) + plot(1:10) + }) + }) + }, + ui = function(id) { + ns <- NS(id) + tagList( + numericInput(ns("number"), "Enter a number:", value = 3, min = 1, max = 10), + plotOutput(ns("plot")) + ) + } + ) + } + + app <- TealAppDriver$new( + init( + data = simple_teal_data(), + modules = validation_module(label = "Validation Test") + ) + ) + + # Check that validation error is displayed in the output + error_text <- app$get_text(".shiny-output-error-validation") + testthat::expect_match(error_text, "Please select a number greater than 5") + + + # Update input to valid value + app$set_input(app$namespaces()$module("number"), 7) + app$wait_for_idle() + + # Check that validation error is gone + testthat::expect_null(app$get_text(".shiny-output-error-validation")) + + app$stop() +}) + +testthat::test_that("e2e: validate_input validates many inputs (and linked output) when they return invalid value", { + skip_if_too_deep(5) + + multi_validation_module <- function(label = "multi validation") { + module( + label = label, + server = function(id, data) { + moduleServer(id, function(input, output, session) { + output$result <- renderText({ + teal:::validate_input( + inputId = c("input1", "input2"), + condition = function(x, y) identical(x, y), + message = "x and y must be identical", + session = session + ) + paste("Valid inputs:", input$input1, input$input2) + }) + }) + }, + ui = function(id) { + ns <- NS(id) + tagList( + numericInput(ns("input1"), "Enter y:", value = 4, min = 0, max = 100), + numericInput(ns("input2"), "Enter y:", value = 5, min = 0, max = 100), + textOutput(ns("result")) + ) + } + ) + } + + app <- TealAppDriver$new( + init( + data = simple_teal_data(), + modules = multi_validation_module(label = "Multi Validation") + ) + ) + + # Initially input2 is invalid (value 5 < 10) + error_text <- app$get_text(".shiny-output-error-validation") + testthat::expect_match(error_text, "x and y must be identical") + testthat::expect_match( + app$get_text(app$namespaces(TRUE)$module("result")), + "x and y must be identical" + ) + + # Update input2 to valid value + app$set_input(app$namespaces()$module("input1"), 5) + app$set_input(app$namespaces()$module("input2"), 5) + app$wait_for_idle() + + # Check that validation passes and result is shown + error_text <- app$get_text(".shiny-output-error-validation") + testthat::expect_null(error_text) + + testthat::expect_match( + app$get_text(app$namespaces(TRUE)$module("result")), + "Valid inputs: 5 5" + ) + + app$stop() +}) + +testthat::test_that("e2e: validate_input validates sliderInput") +testthat::test_that("e2e: validate_input validates selectInput") +testthat::test_that("e2e: validate_input validates selectizeInput") +testthat::test_that("e2e: validate_input validates radioButtons") +testthat::test_that("e2e: validate_input validates checkboxGroupInput") +testthat::test_that("e2e: validate_input validates checkboxInput") +testthat::test_that("e2e: validate_input validates dateInput") +testthat::test_that("e2e: validate_input validates dateRangeInput") diff --git a/tests/testthat/test-validate_input.R b/tests/testthat/test-validate_input.R new file mode 100644 index 0000000000..eecd4b9b6e --- /dev/null +++ b/tests/testthat/test-validate_input.R @@ -0,0 +1,188 @@ +testthat::test_that("validate_input(inputId) has to be a character(1)", { + testthat::expect_error( + validate_input(inputId = character(0)), + "Assertion on 'inputId' failed" + ) + testthat::expect_error( + validate_input(inputId = 123), + "Assertion on 'inputId' failed" + ) + testthat::expect_error( + validate_input(inputId = NULL), + "Assertion on 'inputId' failed" + ) +}) + +testthat::test_that("validate_input(condition) has to be a logical(1) or function with nargs = length(inputId)", { + testthat::expect_error( + validate_input(inputId = "test", condition = "not_logical"), + "Assertion failed" + ) + testthat::expect_error( + validate_input(inputId = "test", condition = c(TRUE, FALSE)), + "Assertion failed" + ) + + testthat::expect_error( + validate_input(inputId = "test", condition = function(x, y) TRUE), + "Assertion failed" + ) + + testthat::expect_error( + validate_input(inputId = c("test", "test2"), condition = function(x) TRUE), + "Assertion failed" + ) +}) + +testthat::test_that("validate_input(message) has to be a character(1)", { + testthat::expect_error( + validate_input(inputId = "test", message = 123), + "Assertion on 'message' failed" + ) + testthat::expect_error( + validate_input(inputId = "test", message = c("a", "b")), + "Assertion on 'message' failed" + ) +}) + +testthat::test_that("validate_input returns NULL when condition is TRUE", { + test_module <- function(id) { + moduleServer(id, function(input, output, session) { + result <- reactive({ + validate_input( + inputId = "test_input", + condition = TRUE, + message = "Test message", + session = session + ) + }) + }) + } + + shiny::testServer( + app = test_module, + args = list(id = "test"), + expr = testthat::expect_null(result()) + ) +}) + +testthat::test_that("validate_input throws `message` as shiny.silent.error when `condition = FALSE`", { + test_module <- function(id) { + moduleServer(id, function(input, output, session) { + result <- reactive({ + validate_input( + inputId = "test_input", + condition = FALSE, + message = "Test message", + session = session + ) + }) + }) + } + + shiny::testServer( + app = test_module, + args = list(id = "test"), + expr = testthat::expect_error(result(), "Test message") + ) +}) + +testthat::test_that("validate_input works with function condition that returns `TRUE` for input with `inputId`", { + test_module <- function(id) { + moduleServer(id, function(input, output, session) { + result <- reactive({ + validate_input( + inputId = "test_input", + condition = function(x) identical(x, "valid_value"), + message = "Input is invalid", + session = session + ) + }) + }) + } + + shiny::testServer( + app = test_module, + expr = { + # Set up input value + session$setInputs(test_input = "valid_value") + testthat::expect_null(result()) + }, + args = list(id = "test") + ) +}) + +testthat::test_that("validate_input works with function condition that returns `FALSE` for input with `inputId`", { + test_module <- function(id) { + moduleServer(id, function(input, output, session) { + result <- reactive({ + validate_input( + inputId = "test_input", + condition = function(x) identical(x, "valid_value"), + message = "Input is invalid", + session = session + ) + }) + }) + } + + shiny::testServer( + app = test_module, + expr = { + # Set up input value + session$setInputs(test_input = "invalid_value") + testthat::expect_error(result(), "Input is invalid") + }, + args = list(id = "test") + ) +}) + +testthat::test_that("validate_input with multiple inputIds throws shiny.silent.error when any input is invalid", { + test_module <- function(id) { + moduleServer(id, function(input, output, session) { + result <- reactive({ + validate_input( + inputId = c("test_input1", "test_input2"), + condition = function(x, y) identical(x, y), + message = "Are not identical", + session = session + ) + }) + }) + } + + shiny::testServer( + app = test_module, + expr = { + # Set up input value + session$setInputs(test_input1 = 99, test_input2 = 98) + testthat::expect_error(result(), "Are not identical") + }, + args = list(id = "test") + ) +}) + +testthat::test_that("validate_input with multiple inputIds doesn't throw shiny.silent.error if all inputs are valid", { + test_module <- function(id) { + moduleServer(id, function(input, output, session) { + result <- reactive({ + validate_input( + inputId = c("test_input1", "test_input2"), + condition = function(x, y) identical(x, y), + message = "Input is invalid", + session = session + ) + }) + }) + } + + shiny::testServer( + app = test_module, + expr = { + # Set up input value + session$setInputs(test_input1 = 99, test_input2 = 99) + testthat::expect_no_error(result()) + }, + args = list(id = "test") + ) +})