diff --git a/NAMESPACE b/NAMESPACE index 7d624080..01b4ca02 100644 --- a/NAMESPACE +++ b/NAMESPACE @@ -86,4 +86,5 @@ export(validate_field) export(value) export(values) export(variable_field) +import(shiny) importFrom(magrittr,"%>%") diff --git a/R/server.R b/R/server.R index 75ad95d1..8fe1590d 100644 --- a/R/server.R +++ b/R/server.R @@ -1,11 +1,12 @@ -#' UI +#' Server #' -#' Generic for UI generation +#' Generic for server generation #' -#' @param x Object for which to generate a [shiny::moduleServer()] +#' @param x Object for which to generate a [moduleServer()] #' @param ... Generic consistency #' #' @export +#' @import shiny generate_server <- function(x, ...) { UseMethod("generate_server") } @@ -34,25 +35,45 @@ generate_server.data_block <- function(x, ...) { ) } - shiny::moduleServer( + moduleServer( attr(x, "name"), function(input, output, session) { + ns <- session$ns + blk <- reactiveVal(x) - blk <- shiny::reactiveVal(x) - - shiny::observeEvent( + o <- observeEvent( eval(obs_expr(blk())), eval(set_expr(blk())), ignoreInit = TRUE ) - out_dat <- shiny::reactive( + out_dat <- reactive( evalute_block(blk()) ) output <- server_output(x, out_dat, output) output <- server_code(x, blk, output) + # Cleanup module inputs (UI and server side) + # and observer + observeEvent(input$remove, { + # Trick to be able to tell the stack to wait + # for this event to run. + session$userData$is_cleaned(FALSE) + # Can only remove when it is the last stack block + if (length(session$userData$stack) == 1) { + message(sprintf("CLEANING UP BLOCK %s", attr(x, "name"))) + removeUI(sprintf("#%s", ns("block")), immediate = TRUE) + remove_shiny_inputs(id = attr(x, "name"), input) + o$destroy() + session$userData$is_cleaned(TRUE) + } + # We have to set high priority so this event + # executes before the one in the stack which + # updates the stack. If we don't, this will + # never execute because the stack will be empty :) + }, priority = 500) + out_dat } ) @@ -77,25 +98,35 @@ generate_server.transform_block <- function(x, in_dat, ...) { ) } - shiny::moduleServer( + moduleServer( attr(x, "name"), function(input, output, session) { + ns <- session$ns + blk <- reactiveVal(x) - blk <- shiny::reactiveVal(x) - - shiny::observeEvent( + o <- observeEvent( eval(obs_expr(blk())), eval(set_expr(blk())), ignoreInit = TRUE ) - out_dat <- shiny::reactive( + out_dat <- reactive( evalute_block(blk(), data = in_dat()) ) output <- server_output(x, out_dat, output) output <- server_code(x, blk, output) + # Cleanup module inputs (UI and server side) + # and observer + observeEvent(input$remove, { + message(sprintf("CLEANING UP BLOCK %s", attr(x, "name"))) + removeUI(sprintf("#%s", ns("block"))) + remove_shiny_inputs(id = attr(x, "name"), input) + o$destroy() + session$userData$is_cleaned(TRUE) + }) + out_dat } ) @@ -107,20 +138,159 @@ generate_server.stack <- function(x, ...) { stopifnot(...length() == 0L) - shiny::moduleServer( + moduleServer( attr(x, "name"), function(input, output, session) { + vals <- reactiveValues(stack = x, blocks = vector("list", length(x))) + init_blocks(x, vals, session) + + # Add block + observeEvent(input$add, { + showModal( + modalDialog( + "TO DO: add a confirm button and a select input to select + which block to add ...", + title = h3(icon("check"), "Add a new block"), + footer = modalButton("Dismiss"), + size = "m", + easyClose = FALSE, + fade = TRUE + ) + ) + }) + + observeEvent(input$add, { + # Update stack + block_to_add <- if (length(vals$stack) == 0) { + data_block + } else { + filter_block + } + + vals$stack[[length(vals$stack) + 1]] <- do.call( + block_to_add, + if (length(vals$stack) == 0) { + list() + } else { + list(vals$blocks[[length(vals$stack)]]()) + } + ) + # Call module + vals$blocks[[length(vals$stack)]] <- generate_server( + vals$stack[[length(vals$stack)]], + in_dat = if (length(vals$stack) == 1) { + # No data for first block + NULL + } else { + # Data from previous block + vals$blocks[[length(vals$stack) - 1]] + } + ) + + # Correct selector + if (length(vals$stack) == 1) { + # If this is the first module inserted, + # we target the body container. + # TO DO: we should actually have a proper UI for the stack + # to avoid targeting .container-fluid ... + selector <- ".container-fluid" + } else { + # Target the previous block + selector <- sprintf( + "#%s-%s-block", + attr(vals$stack, "name"), + attr(vals$stack[[length(vals$stack) - 1]], "name") + ) + } + + # Insert UI after last block + insertUI( + selector, + where = "afterEnd", + ui = generate_ui( + vals$stack[[length(vals$stack)]], + id = attr(vals$stack, "name") + ) + ) + + # Necessary to communicate with downstream modules + session$userData$stack <- vals$stack + }) + + # Remove block from stack (can't be done within the block) + to_remove <- reactive({ + req(input$last_changed) + if (grepl("remove", input$last_changed$name)) { + req(input$last_changed$value > 0) + } + + # Retrieve index of block to remove + blocks_ids <- paste( + attr(x, "name"), + vapply(vals$stack, \(x) attr(x, "name"), FUN.VALUE = character(1)), + sep = "-" + ) + block_id <- strsplit(input$last_changed$name, "-remove")[[1]][1] + tmp <- which(blocks_ids == block_id) + req(length(tmp) > 0) + tmp + }) + + session$userData$is_cleaned <- reactiveVal(FALSE) + + observeEvent({ + c( + to_remove(), + session$userData$is_cleaned() + ) + }, { + # We can't remove the data block if there are downstream consumers... + if (to_remove() == 1 && length(vals$stack) > 1) { + showModal( + modalDialog( + title = h3(icon("xmark"), "Error"), + "Can't remove a datablock whenever there are + downstream data block consumers." + ) + ) + } else { + if (session$userData$is_cleaned()) { + message(sprintf("REMOVING BLOCK %s", to_remove())) + vals$stack[[to_remove()]] <- NULL + session$userData$stack <- vals$stack + session$userData$is_cleaned(FALSE) + } + } + }) + + vals - res <- vector("list", length(x)) - - res[[1L]] <- generate_server(x[[1L]]) - - for (i in seq_along(x)[-1L]) { - res[[i]] <- generate_server(x[[i]], in_dat = res[[i - 1L]]) - } + } + ) +} - res +#' Init blocks server +#' @keywords internal +init_blocks <- function(x, vals, session) { + observeEvent(TRUE, { + session$userData$stack <- vals$stack + vals$blocks[[1L]] <- generate_server(x[[1L]]) + for (i in seq_along(x)[-1L]) { + vals$blocks[[i]] <- generate_server( + x[[i]], + in_dat = vals$blocks[[i - 1L]] + ) } + }) +} + +#' Cleanup module inputs +#' @keywords internal +remove_shiny_inputs <- function(id, .input) { + invisible( + lapply(grep(id, names(.input), value = TRUE), function(i) { + .subset2(.input, "impl")$.values$remove(i) + }) ) } @@ -135,7 +305,7 @@ server_output <- function(x, result, output) { #' @rdname generate_ui #' @export server_output.block <- function(x, result, output) { - output$output <- shiny::renderPrint(result()) + output$output <- renderPrint(result()) output } @@ -150,7 +320,7 @@ server_code <- function(x, state, output) { #' @export server_code.block <- function(x, state, output) { - output$code <- shiny::renderPrint( + output$code <- renderPrint( cat(deparse(generate_code(state())), sep = "\n") ) diff --git a/R/stack.R b/R/stack.R index 531b1a7d..97c95cda 100644 --- a/R/stack.R +++ b/R/stack.R @@ -42,5 +42,5 @@ serve_stack <- function(stack) { generate_server(stack) } - shiny::shinyApp(ui, server) + shinyApp(ui, server) } diff --git a/R/ui.R b/R/ui.R index d131cede..ac33e681 100644 --- a/R/ui.R +++ b/R/ui.R @@ -17,8 +17,8 @@ generate_ui.block <- function(x, id, ...) { stopifnot(...length() == 0L) - ns <- shiny::NS( - shiny::NS(id)(attr(x, "name")) + ns <- NS( + NS(id)(attr(x, "name")) ) fields <- Map( @@ -28,12 +28,18 @@ generate_ui.block <- function(x, id, ...) { name = names(x) ) - div_card( - title = shiny::h4(attr(x, "name")), - do.call(shiny::div, unname(fields)), + block_ui <- div_card( + title = h4( + attr(x, "name"), + actionButton(ns("remove"), icon("trash"), class = "pull-right") + ) + , + do.call(div, unname(fields)), ui_code(x, ns), ui_output(x, ns) ) + block_ui$attribs$id <- ns("block") + block_ui } #' @rdname generate_ui @@ -42,12 +48,40 @@ generate_ui.stack <- function(x, ...) { stopifnot(...length() == 0L) - do.call( - shiny::fluidPage, - c( - lapply(x, generate_ui, id = attr(x, "name")), - title = attr(x, "name") - ) + ns <- NS(attr(x, "name")) + + tagList( + tags$script( + HTML( + sprintf( + "$(document).on( + 'shiny:inputchanged', + function(event) { + console.log(event.name); + if (event.name.match('(last_changed|clientdata)') === null) { + Shiny.setInputValue( + '%s', + { + name: event.name, + value: event.value, + type: event.inputType, + binding: event.binding !== null ? event.binding.name : '' + } + ); + } + });", + ns("last_changed") + ) + ) + ), + do.call( + fluidPage, + c( + lapply(x, generate_ui, id = attr(x, "name")), + title = attr(x, "name") + ) + ), + actionButton(ns("add"), icon("plus")) ) } @@ -61,13 +95,13 @@ ui_input <- function(x, id, name) { #' @rdname generate_ui #' @export ui_input.string_field <- function(x, id, name) { - shiny::textInput(input_ids(x, id), name, value(x)) + textInput(input_ids(x, id), name, value(x)) } #' @rdname generate_ui #' @export ui_input.select_field <- function(x, id, name) { - shiny::selectInput( + selectInput( input_ids(x, id), name, value(x, "choices"), value(x), value(x, "multiple") ) } @@ -105,7 +139,7 @@ ui_input.variable_field <- function(x, id, name) { materialize_variable_field(x) ) - shiny::div( + div( id = paste0(id, "_cont"), ui_input(field, id, name) ) @@ -114,7 +148,7 @@ ui_input.variable_field <- function(x, id, name) { #' @rdname generate_ui #' @export ui_input.range_field <- function(x, id, name) { - shiny::sliderInput( + sliderInput( input_ids(x, id), name, value(x, "min"), value(x, "max"), value(x) ) } @@ -142,7 +176,7 @@ ui_input.list_field <- function(x, id, name) { map(ui_input, fields, input_ids(x, id), names(fields)) ) - do.call(shiny::div, args) + do.call(div, args) } #' @param session Shiny session @@ -155,13 +189,13 @@ ui_update <- function(x, session, id, name) { #' @rdname generate_ui #' @export ui_update.string_field <- function(x, session, id, name) { - shiny::updateTextInput(session, input_ids(x, id), name, value(x)) + updateTextInput(session, input_ids(x, id), name, value(x)) } #' @rdname generate_ui #' @export ui_update.select_field <- function(x, session, id, name) { - shiny::updateSelectInput( + updateSelectInput( session, input_ids(x, id), name, value(x, "choices"), value(x) ) } @@ -173,7 +207,7 @@ ui_update.variable_field <- function(x, session, id, name) { ns <- session$ns ns_id <- ns(id) - shiny::removeUI( + removeUI( selector = paste0("#", ns_id, "_cont", " > div"), session = session ) @@ -182,7 +216,7 @@ ui_update.variable_field <- function(x, session, id, name) { materialize_variable_field(x) ) - shiny::insertUI( + insertUI( selector = paste0("#", ns_id, "_cont"), ui = ui_input(field, ns_id, name), session = session @@ -192,7 +226,7 @@ ui_update.variable_field <- function(x, session, id, name) { #' @rdname generate_ui #' @export ui_update.range_field <- function(x, session, id, name) { - shiny::updateSliderInput( + updateSliderInput( session, input_ids(x, id), name, value(x), value(x, "min"), value(x, "max") ) } @@ -210,7 +244,7 @@ ui_update.list_field <- function(x, session, id, name) { ns <- session$ns ns_id <- ns(id) - shiny::removeUI( + removeUI( selector = paste0("#", ns_id, "_cont", " > div"), multiple = TRUE, session = session @@ -221,10 +255,10 @@ ui_update.list_field <- function(x, session, id, name) { validate_field ) - shiny::insertUI( + insertUI( selector = paste0("#", ns_id, "_cont"), ui = do.call( - shiny::tagList, + tagList, map( ui_input, fields, input_ids(x, ns_id), paste0(name, "_", names(fields)) ) @@ -234,18 +268,18 @@ ui_update.list_field <- function(x, session, id, name) { } div_card <- function(..., title = NULL, footer = NULL) { - shiny::div( + div( class = "panel panel-default", style = "margin: 10px", if (not_null(title)) { - shiny::div(title, class = "panel-heading") + div(title, class = "panel-heading") }, - shiny::div( + div( class = "panel-body", ... ), if (not_null(footer)) { - shiny::div(footer, class = "panel-footer") + div(footer, class = "panel-footer") } ) } @@ -260,7 +294,7 @@ ui_output <- function(x, ns) { #' @rdname generate_ui #' @export ui_output.block <- function(x, ns) { - shiny::verbatimTextOutput(ns("output")) + verbatimTextOutput(ns("output")) } #' @rdname generate_ui @@ -272,5 +306,5 @@ ui_code <- function(x, ns) { #' @rdname generate_ui #' @export ui_code.block <- function(x, ns) { - shiny::verbatimTextOutput(ns("code")) + verbatimTextOutput(ns("code")) } diff --git a/man/generate_server.Rd b/man/generate_server.Rd index ae2b63bb..695c1e75 100644 --- a/man/generate_server.Rd +++ b/man/generate_server.Rd @@ -6,7 +6,7 @@ \alias{generate_server.data_block} \alias{generate_server.transform_block} \alias{generate_server.stack} -\title{UI} +\title{Server} \usage{ generate_server(x, ...) @@ -19,12 +19,12 @@ generate_server(x, ...) \method{generate_server}{stack}(x, ...) } \arguments{ -\item{x}{Object for which to generate a \code{\link[shiny:moduleServer]{shiny::moduleServer()}}} +\item{x}{Object for which to generate a \code{\link[=moduleServer]{moduleServer()}}} \item{...}{Generic consistency} \item{in_dat}{Reactive input data} } \description{ -Generic for UI generation +Generic for server generation } diff --git a/man/init_blocks.Rd b/man/init_blocks.Rd new file mode 100644 index 00000000..2dbb20fa --- /dev/null +++ b/man/init_blocks.Rd @@ -0,0 +1,12 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/server.R +\name{init_blocks} +\alias{init_blocks} +\title{Init blocks server} +\usage{ +init_blocks(x, vals, session) +} +\description{ +Init blocks server +} +\keyword{internal} diff --git a/man/remove_shiny_inputs.Rd b/man/remove_shiny_inputs.Rd new file mode 100644 index 00000000..7fcd6f74 --- /dev/null +++ b/man/remove_shiny_inputs.Rd @@ -0,0 +1,12 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/server.R +\name{remove_shiny_inputs} +\alias{remove_shiny_inputs} +\title{Cleanup module inputs} +\usage{ +remove_shiny_inputs(id, .input) +} +\description{ +Cleanup module inputs +} +\keyword{internal} diff --git a/tests/testthat/test-app.R b/tests/testthat/test-app.R new file mode 100644 index 00000000..45f1c650 --- /dev/null +++ b/tests/testthat/test-app.R @@ -0,0 +1,74 @@ +stack <- new_stack( + data_block, + filter_block +) + +# Test stack module +# A bit hacky but generate_server.stack does not valspect +# the pvalscription for shiny module testing (the function +# must have id as first param. +my_stack <- function(id, x) { + +} +body(my_stack) <- body(generate_server.stack)[-2] +testServer(my_stack, args = list(x = stack), { + # # # # # # # + # # + # INIT TEST # + # # + # # # # # # # + + # Let's check we have correct init state ... + expect_length(vals$stack, 2) + expect_length(vals$blocks, 2) + + # Test data block + data_block <- vals$stack[[1]] + data_block_field <- data_block$dataset + expect_true(inherits(data_block, "data_block")) + expect_equal(data_block_field$multiple, FALSE) + expect_equal(data_block_field$value, "iris") + + # test filter block + filter_block <- vals$stack[[2]] + filter_block_col_field <- filter_block$columns + filter_block_val_field <- filter_block$values + expect_true(inherits(filter_block, "filter_block")) + expect_equal(filter_block_col_field$value, colnames(iris)[[1]]) + expect_equal(attr(filter_block_col_field$choices, "result"), colnames(iris)) + expect_equal(filter_block_col_field$multiple, TRUE) + + # # # # # # # # # # + # # + # REACTIVITY TEST # + # # + # # # # # # # # # # + + # Let's make some change + session$setInputs(add = 1) + expect_length(vals$stack, 3) + expect_length(vals$blocks, 3) + expect_true(inherits(filter_block, "filter_block")) + # Test user data necessary to communicate with submodules + expect_equal(ls(session$userData), c("is_cleaned", "stack")) + expect_equal(length(session$userData$stack), length(vals$stack)) + expect_false(session$userData$is_cleaned()) +}) + +# Test data block module +# This is a bit limited since we don't know the +# stack state. shinytest2 would be more accurate here ... +my_data_block <- function(id, x) { + +} +body(my_data_block) <- body(generate_server.data_block) +testServer(my_data_block, args = list(x = data_block()), { + expect_equal(colnames(out_dat()), colnames(iris)) + expect_equal(nrow(out_dat()), nrow(iris)) + session$userData$is_cleaned <- reactiveVal(NULL) + session$userData$stack <- 1 + expect_false(o$.destroyed) + expect_message(session$setInputs(remove = 1)) + expect_true(session$userData$is_cleaned()) + expect_true(o$.destroyed) +})