diff --git a/DESCRIPTION b/DESCRIPTION index bbd973d..f124961 100644 --- a/DESCRIPTION +++ b/DESCRIPTION @@ -38,6 +38,7 @@ Suggests: knitr, magrittr, rmarkdown, + shiny, spelling, testthat (>= 3.0.0) Collate: @@ -49,6 +50,7 @@ Collate: complex_layout.R pharma_layout.R get_layouts.R + gridify_with_settings.R layout_issues.R pagination_utils.R VignetteBuilder: knitr diff --git a/NAMESPACE b/NAMESPACE index 5954386..5b742e5 100644 --- a/NAMESPACE +++ b/NAMESPACE @@ -8,6 +8,8 @@ export(gridifyCell) export(gridifyCells) export(gridifyLayout) export(gridifyObject) +export(gridify_with_settings_srv) +export(gridify_with_settings_ui) export(paginate_table) export(pharma_layout_A4) export(pharma_layout_base) diff --git a/R/gridify_with_settings.R b/R/gridify_with_settings.R new file mode 100644 index 0000000..6d18278 --- /dev/null +++ b/R/gridify_with_settings.R @@ -0,0 +1,230 @@ +#' Shiny module to render a gridify object with size controls +#' +#' @name gridify_with_settings +#' @rdname gridify_with_settings +#' +#' @description +#' A Shiny module that renders a [`gridifyClass-class`] object (from [gridify()]) +#' with height and width sliders, plus PNG and PDF download buttons. +#' +#' @param id (`character(1)`) Shiny module id. +#' +#' @seealso [gridify()], [set_cell()], [gridifyLayout()] +#' +#' @examples +#' if (interactive() && requireNamespace("shiny", quietly = TRUE) && +#' requireNamespace("ggplot2", quietly = TRUE)) { +#' library(shiny) +#' library(ggplot2) +#' library(gridify) +#' +#' ui <- fluidPage( +#' gridify_with_settings_ui("demo") +#' ) +#' +#' server <- function(input, output, session) { +#' gridify_r <- reactive({ +#' gridify( +#' object = ggplot2::ggplot(mtcars, ggplot2::aes(mpg, wt)) + +#' ggplot2::geom_point(), +#' layout = simple_layout() +#' ) |> +#' set_cell("title", "My Plot") +#' }) +#' +#' gridify_with_settings_srv("demo", gridify_r = gridify_r) +#' } +#' +#' shinyApp(ui, server) +#' } +NULL + +#' @noRd +gridify_require_shiny <- function() { + if (!requireNamespace("shiny", quietly = TRUE)) { + stop( + "The 'shiny' package is required to use gridify_with_settings.\n", + "Install it with: install.packages(\"shiny\")", + call. = FALSE + ) + } +} + +#' @noRd +assert_hwvec <- function(x, nm) { + if (!is.numeric(x) || length(x) != 3L || any(!is.finite(x))) { + stop( + "'", nm, "' must be a numeric vector of length 3 (value, min, max).", + call. = FALSE + ) + } + if (x[1L] < x[2L] || x[1L] > x[3L]) { + stop( + "'", nm, "' value (", x[1L], ") must be between min (", x[2L], + ") and max (", x[3L], ").", + call. = FALSE + ) + } +} + +#' @rdname gridify_with_settings +#' +#' @return +#' `gridify_with_settings_ui()` returns a `shiny::sidebarLayout`. +#' +#' @export +gridify_with_settings_ui <- function(id) { + gridify_require_shiny() + if (!is.character(id) || length(id) != 1L) { + stop("'id' must be a single character string.", call. = FALSE) + } + + ns <- shiny::NS(id) + + shiny::sidebarLayout( + sidebarPanel = shiny::sidebarPanel( + width = 3, + shiny::sliderInput( + inputId = ns("height"), + label = "Height (px)", + min = 200L, + max = 2000L, + value = 600L, + step = 10L, + ticks = FALSE + ), + shiny::sliderInput( + inputId = ns("width"), + label = "Width (px)", + min = 200L, + max = 2000L, + value = 800L, + step = 10L, + ticks = FALSE + ), + shiny::tags$hr(), + shiny::downloadButton(ns("dl_png"), "PNG", class = "btn-sm"), + shiny::tags$span(" "), + shiny::downloadButton(ns("dl_pdf"), "PDF", class = "btn-sm") + ), + mainPanel = shiny::mainPanel( + width = 9, + shiny::uiOutput(ns("plot_ui")) + ) + ) +} + +#' @rdname gridify_with_settings +#' +#' @param gridify_r (`reactive` or `function`) +#' A `shiny::reactive()` (or plain function) returning a [`gridifyClass-class`] +#' object produced by [gridify()]. +#' +#' @param height (`numeric(3)`) +#' Height slider values `c(value, min, max)` in pixels. +#' +#' @param width (`numeric(3)`) +#' Width slider values `c(value, min, max)` in pixels. +#' +#' @return +#' `gridify_with_settings_srv()` invisibly returns `NULL`. +#' +#' @export +gridify_with_settings_srv <- function( + id, + gridify_r, + height = c(600L, 200L, 2000L), + width = c(800L, 200L, 2000L)) { + + gridify_require_shiny() + + if (!is.character(id) || length(id) != 1L) { + stop("'id' must be a single character string.", call. = FALSE) + } + if (!inherits(gridify_r, c("function", "reactive"))) { + stop("'gridify_r' must be a shiny::reactive() or a plain function.", + call. = FALSE) + } + + assert_hwvec(height, "height") + assert_hwvec(width, "width") + + shiny::moduleServer(id, function(input, output, session) { + + shiny::observe({ + shiny::updateSliderInput( + session, "height", + min = height[2], max = height[3], value = height[1] + ) + shiny::updateSliderInput( + session, "width", + min = width[2], max = width[3], value = width[1] + ) + }) |> shiny::bindEvent(session$token, ignoreNULL = TRUE, once = TRUE) + + get_obj <- shiny::reactive({ + obj <- if (inherits(gridify_r, "reactive")) gridify_r() else gridify_r() + if (!methods::is(obj, "gridifyClass")) { + stop( + "gridify_with_settings_srv: 'gridify_r' must return a 'gridifyClass' object.\n", + "Received class: ", paste(class(obj), collapse = ", "), + call. = FALSE + ) + } + obj + }) + + p_height <- shiny::reactive(as.integer(input$height)) + p_width <- shiny::reactive(as.integer(input$width)) + + output$plot_ui <- shiny::renderUI({ + shiny::plotOutput( + outputId = shiny::NS(id)("plot_out"), + height = paste0(p_height(), "px"), + width = paste0(p_width(), "px") + ) + }) + + output$plot_out <- shiny::renderPlot( + expr = { + methods::show(get_obj()) + }, + height = p_height, + width = p_width + ) + + output$dl_png <- shiny::downloadHandler( + filename = function() { + paste0("gridify_", format(Sys.time(), "%Y%m%d_%H%M%S"), ".png") + }, + content = function(file) { + grDevices::png( + filename = file, + width = p_width(), + height = p_height(), + units = "px", + res = 96L + ) + print(get_obj()) + grDevices::dev.off() + } + ) + + output$dl_pdf <- shiny::downloadHandler( + filename = function() { + paste0("gridify_", format(Sys.time(), "%Y%m%d_%H%M%S"), ".pdf") + }, + content = function(file) { + grDevices::pdf( + file = file, + width = p_width() / 96, + height = p_height() / 96 + ) + print(get_obj()) + grDevices::dev.off() + } + ) + + invisible(NULL) + }) +} diff --git a/_pkgdown.yml b/_pkgdown.yml index 18b3009..4259b5e 100644 --- a/_pkgdown.yml +++ b/_pkgdown.yml @@ -15,6 +15,7 @@ articles: - simple_examples - multi_page_examples - create_custom_layout + - shiny_integration - transparency reference: @@ -47,6 +48,10 @@ reference: contents: - paginate_table + - subtitle: Shiny integration + contents: + - gridify_with_settings + - title: gridify custom layout development - subtitle: functions contents: diff --git a/man/gridify_with_settings.Rd b/man/gridify_with_settings.Rd new file mode 100644 index 0000000..aabca15 --- /dev/null +++ b/man/gridify_with_settings.Rd @@ -0,0 +1,69 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/gridify_with_settings.R +\name{gridify_with_settings} +\alias{gridify_with_settings} +\alias{gridify_with_settings_ui} +\alias{gridify_with_settings_srv} +\title{Shiny module to render a gridify object with size controls} +\usage{ +gridify_with_settings_ui(id) + +gridify_with_settings_srv( + id, + gridify_r, + height = c(600L, 200L, 2000L), + width = c(800L, 200L, 2000L) +) +} +\arguments{ +\item{id}{(\code{character(1)}) Shiny module id.} + +\item{gridify_r}{(\code{reactive} or \code{function}) +A \code{shiny::reactive()} (or plain function) returning a \code{\linkS4class{gridifyClass}} +object produced by \code{\link[=gridify]{gridify()}}.} + +\item{height}{(\code{numeric(3)}) +Height slider values \code{c(value, min, max)} in pixels.} + +\item{width}{(\code{numeric(3)}) +Width slider values \code{c(value, min, max)} in pixels.} +} +\value{ +\code{gridify_with_settings_ui()} returns a \code{shiny::sidebarLayout}. + +\code{gridify_with_settings_srv()} invisibly returns \code{NULL}. +} +\description{ +A Shiny module that renders a \code{\linkS4class{gridifyClass}} object (from \code{\link[=gridify]{gridify()}}) +with height and width sliders, plus PNG and PDF download buttons. +} +\examples{ +if (interactive() && requireNamespace("shiny", quietly = TRUE) && + requireNamespace("ggplot2", quietly = TRUE)) { + library(shiny) + library(ggplot2) + library(gridify) + + ui <- fluidPage( + gridify_with_settings_ui("demo") + ) + + server <- function(input, output, session) { + gridify_r <- reactive({ + gridify( + object = ggplot2::ggplot(mtcars, ggplot2::aes(mpg, wt)) + + ggplot2::geom_point(), + layout = simple_layout() + ) |> + set_cell("title", "My Plot") + }) + + gridify_with_settings_srv("demo", gridify_r = gridify_r) + } + + shinyApp(ui, server) +} +} +\seealso{ +\code{\link[=gridify]{gridify()}}, \code{\link[=set_cell]{set_cell()}}, \code{\link[=gridifyLayout]{gridifyLayout()}} +} diff --git a/tests/testthat/test-gridify_with_settings.R b/tests/testthat/test-gridify_with_settings.R new file mode 100644 index 0000000..f7acb9f --- /dev/null +++ b/tests/testthat/test-gridify_with_settings.R @@ -0,0 +1,104 @@ +skip_if_not_installed("shiny") +skip_if_not_installed("ggplot2") + +make_gridify_obj <- function() { + gridify( + object = ggplot2::ggplot(mtcars, ggplot2::aes(mpg, wt)) + + ggplot2::geom_point(), + layout = simple_layout() + ) |> + set_cell("title", "Test title") |> + set_cell("footer", "Test footer") +} + +test_that("gridify_with_settings_ui returns a shiny.tag", { + ui <- gridify_with_settings_ui("mod") + expect_s3_class(ui, "shiny.tag") +}) + +test_that("gridify_with_settings_ui validates id", { + expect_error(gridify_with_settings_ui(1), "single character string") + expect_error(gridify_with_settings_ui(c("a", "b")), "single character string") +}) + +test_that("gridify_with_settings_ui includes key controls", { + ui <- gridify_with_settings_ui("check") + html <- as.character(ui) + expect_true(grepl("check-plot_ui", html, fixed = TRUE)) + expect_true(grepl("check-height", html, fixed = TRUE)) + expect_true(grepl("check-width", html, fixed = TRUE)) + expect_true(grepl("dl_png", html, fixed = TRUE)) + expect_true(grepl("dl_pdf", html, fixed = TRUE)) +}) + +test_that("gridify_with_settings_srv validates arguments", { + expect_error( + gridify_with_settings_srv(123, shiny::reactive(make_gridify_obj())), + "single character string" + ) + + expect_error( + gridify_with_settings_srv("id", "x"), + "must be a shiny::reactive() or a plain function", + fixed = TRUE + ) + + expect_error( + gridify_with_settings_srv("id", shiny::reactive(make_gridify_obj()), height = c(1, 2)), + "numeric vector of length 3" + ) + + expect_error( + gridify_with_settings_srv("id", shiny::reactive(make_gridify_obj()), width = c(1, 2)), + "numeric vector of length 3" + ) + + expect_error( + gridify_with_settings_srv("id", shiny::reactive(make_gridify_obj()), height = c(100, 200, 2000)), + "must be between min" + ) + + expect_error( + gridify_with_settings_srv("id", shiny::reactive(make_gridify_obj()), width = c(100, 200, 2000)), + "must be between min" + ) +}) + +test_that("gridify_with_settings_srv returns NULL", { + gridify_r <- shiny::reactive(make_gridify_obj()) + + shiny::testServer( + app = gridify_with_settings_srv, + args = list(gridify_r = gridify_r), + expr = { + expect_null(session$getReturned()) + } + ) +}) + +test_that("gridify_with_settings_srv checks object class", { + bad_r <- shiny::reactive("not a gridify object") + + shiny::testServer( + app = gridify_with_settings_srv, + args = list(gridify_r = bad_r), + expr = { + session$setInputs(height = 600L, width = 800L) + expect_error(get_obj(), "must return a 'gridifyClass' object") + } + ) +}) + +test_that("gridify_with_settings_srv tracks slider inputs", { + gridify_r <- shiny::reactive(make_gridify_obj()) + + shiny::testServer( + app = gridify_with_settings_srv, + args = list(gridify_r = gridify_r), + expr = { + session$setInputs(height = 610L, width = 920L) + expect_equal(as.integer(input$height), 610L) + expect_equal(as.integer(input$width), 920L) + } + ) +}) diff --git a/vignettes/shiny_integration.Rmd b/vignettes/shiny_integration.Rmd new file mode 100644 index 0000000..ce810c1 --- /dev/null +++ b/vignettes/shiny_integration.Rmd @@ -0,0 +1,205 @@ +--- +title: "Shiny Integration" +output: + rmarkdown::html_vignette: + toc: true + toc_depth: 2 +vignette: > + %\VignetteIndexEntry{Shiny Integration} + %\VignetteEngine{knitr::rmarkdown} + %\VignetteEncoding{UTF-8} +editor_options: + markdown: + wrap: 72 +--- + +```{r, include = FALSE} +knitr::opts_chunk$set( + collapse = TRUE, + comment = "#>", + eval = FALSE +) +``` + +## Introduction + +`gridify()` returns a grid graphical object, so it should be rendered in Shiny +using `plotOutput()` and `renderPlot()`. + +`gridify_with_settings` is a compact module that wraps this pattern and adds +height/width sliders with PNG and PDF download buttons. + +For more detail on layout options and `set_cell()` usage, see +`vignette("simple_examples", package = "gridify")`. + +## Example with a `ggplot2` figure + +Here we create a `ggplot2` figure and wrap it in a `gridify` object with +`pharma_layout_base()`. +We then render it in Shiny through `gridify_with_settings_srv()`. + +```{r} +library(shiny) +library(ggplot2) +library(gridify) + +ui <- fluidPage( + titlePanel("gridify + Shiny"), + gridify_with_settings_ui("demo") +) + +server <- function(input, output, session) { + gridify_r <- reactive({ + figure_obj <- ggplot2::ggplot(mtcars, aes(x = mpg, y = wt)) + + ggplot2::geom_point() + + gridify( + object = figure_obj, + layout = pharma_layout_base() + ) |> + set_cell("header_left_1", "My Company") |> + set_cell("header_left_2", " / ") |> + set_cell("header_left_3", "") |> + set_cell("header_right_2", "") |> + set_cell("output_num", "
xx.xx.xx") |> + set_cell("title_1", "") |> + set_cell("title_2", "<Title 2>") |> + set_cell("note", "<Note or Footnotes>") |> + set_cell("references", "<References:>") |> + set_cell("footer_left", "Program: <PROGRAM NAME>, YYYY-MM-DD at HH:MM") |> + set_cell("footer_right", "Page 1 of 1") + }) + + gridify_with_settings_srv("demo", gridify_r = gridify_r) +} + +shinyApp(ui, server) +``` + +## Example with a base R figure + +Here we create a base R plot as a formula object and pass it to `gridify()`. +We then apply `complex_layout()` and render it with +`gridify_with_settings_srv()`. + +```{r} +library(shiny) +library(gridify) + +ui <- fluidPage( + titlePanel("gridify + Shiny + base R"), + gridify_with_settings_ui("base_demo") +) + +server <- function(input, output, session) { + gridify_r <- reactive({ + formula_object <- ~ barplot(1:10) + + gridify( + object = formula_object, + layout = complex_layout() + ) |> + set_cell("header_left", "Left Header") |> + set_cell("header_middle", "Middle Header") |> + set_cell("header_right", "Right Header") |> + set_cell("title", "Title") |> + set_cell("subtitle", "Subtitle") |> + set_cell("note", "Note") |> + set_cell("footer_left", "Left Footer") |> + set_cell("footer_middle", "Middle Footer") |> + set_cell("footer_right", "Right Footer") + }) + + gridify_with_settings_srv("base_demo", gridify_r = gridify_r) +} + +shinyApp(ui, server) +``` + +## Example with a `flextable` + +Here we create a `flextable` object and pass it to `gridify()` with +`pharma_layout_letter()`. +We then render it in Shiny using `gridify_with_settings_srv()`. + +```{r} +library(shiny) +library(flextable) +library(gridify) + +ui <- fluidPage( + titlePanel("gridify + Shiny + flextable"), + gridify_with_settings_ui("ft_demo") +) + +server <- function(input, output, session) { + gridify_r <- reactive({ + ft <- flextable::flextable(head(mtcars[1:10])) + + gridify( + object = ft, + layout = pharma_layout_letter() + ) |> + set_cell("header_left_1", "My Company") |> + set_cell("header_left_2", "<PROJECT> / <INDICATION>") |> + set_cell("header_left_3", "<STUDY>") |> + set_cell("header_right_2", "<Draft or Final>") |> + set_cell("output_num", "<Table> xx.xx.xx") |> + set_cell("title_1", "<Title 1>") |> + set_cell("title_2", "<Title 2>") |> + set_cell("note", "<Note or Footnotes>") |> + set_cell("references", "<References:>") |> + set_cell("footer_left", "Program: <PROGRAM NAME>, YYYY-MM-DD at HH:MM") |> + set_cell("footer_right", "Page 1 of 1") + }) + + gridify_with_settings_srv("ft_demo", gridify_r = gridify_r) +} + +shinyApp(ui, server) +``` + +## Example with a `gt` table + +Here we create a `gt` table and pass it to `gridify()` with +`pharma_layout_letter()`. +The server then wraps this object with `gridify_with_settings_srv()` for +interactive rendering and download. + +```{r} +library(shiny) +library(gt) +library(gridify) + +ui <- fluidPage( + titlePanel("gridify + Shiny + gt"), + gridify_with_settings_ui("gt_demo") +) + +server <- function(input, output, session) { + gridify_r <- reactive({ + gt_tbl <- gt::gt(head(mtcars[1:10])) + + gridify( + object = gt_tbl, + layout = pharma_layout_letter() + ) |> + set_cell("header_left_1", "My Company") |> + set_cell("header_left_2", "<PROJECT> / <INDICATION>") |> + set_cell("header_left_3", "<STUDY>") |> + set_cell("header_right_2", "<Draft or Final>") |> + set_cell("output_num", "<Table> xx.xx.xx") |> + set_cell("title_1", "<Title 1>") |> + set_cell("title_2", "<Title 2>") |> + set_cell("note", "<Note or Footnotes>") |> + set_cell("references", "<References:>") |> + set_cell("footer_left", "Program: <PROGRAM NAME>, YYYY-MM-DD at HH:MM") |> + set_cell("footer_right", "Page 1 of 1") + }) + + gridify_with_settings_srv("gt_demo", gridify_r = gridify_r) +} + +shinyApp(ui, server) +``` +