From ffc04184b6ed3b23d15b3e2311e6d9eca8440857 Mon Sep 17 00:00:00 2001 From: pposiada Date: Wed, 10 Jun 2026 08:00:49 +0000 Subject: [PATCH 01/10] #46 Add a csv download for reports --- DESCRIPTION | 6 ++- NAMESPACE | 2 + R/ReportManager.R | 92 +++++++++++++++++++++++++++++++++++++++++++ R/tm_report_manager.R | 80 +++++++++++++++++++++++++++++++++++-- man/ReportManager.Rd | 31 ++++++++++++++- 5 files changed, 204 insertions(+), 7 deletions(-) diff --git a/DESCRIPTION b/DESCRIPTION index 9e79510..8f9c206 100644 --- a/DESCRIPTION +++ b/DESCRIPTION @@ -37,6 +37,7 @@ Imports: ggplot2, ggplotify, gridify, + jsonlite, junco, methods, openxlsx, @@ -49,8 +50,10 @@ Imports: teal, teal.code, teal.modules.clinical, + teal.reporter, tern, - yaml + yaml, + zip Suggests: forcats, knitr, @@ -58,7 +61,6 @@ Suggests: rmarkdown, teal.data, teal.modules.general, - teal.reporter, teal.transform VignetteBuilder: knitr Config/Needs/website: insightsengineering/nesttemplate diff --git a/NAMESPACE b/NAMESPACE index 82f4cb0..0c53101 100644 --- a/NAMESPACE +++ b/NAMESPACE @@ -55,6 +55,7 @@ importFrom(gridify,gridifyCells) importFrom(gridify,gridifyLayout) importFrom(gridify,gridifyObject) importFrom(gridify,set_cell) +importFrom(jsonlite,base64_enc) importFrom(methods,new) importFrom(openxlsx,read.xlsx) importFrom(patchwork,plot_annotation) @@ -103,4 +104,5 @@ importFrom(teal.code,eval_code) importFrom(teal.modules.clinical,add_expr) importFrom(teal.modules.clinical,bracket_expr) importFrom(tern,rtable2gg) +importFrom(utils,write.csv) importFrom(yaml,as.yaml) diff --git a/R/ReportManager.R b/R/ReportManager.R index 43d8845..74d8417 100644 --- a/R/ReportManager.R +++ b/R/ReportManager.R @@ -484,6 +484,42 @@ ReportManager <- R6::R6Class("ReportManager", # nolint: object_name_linter } }, + #' Export all tables from a saved report as CSV files + #' @description + #' Loads the report from disk, extracts all table-type content from each card, + #' converts each to a `data.frame` and writes it as a CSV file in `output_dir`. + #' Returns the vector of written file paths, or an empty character vector when + #' no tables are found. + #' @param report_title (character) Title of the report to export. + #' @param output_dir (character) Directory where CSV files will be written. + #' @return character vector of written file paths. + export_tables_to_csv = function(report_title, output_dir) { + report_dir <- self$get_abs_report_path(report_title) + if (!file.exists(file.path(report_dir, "Report.json"))) { + return(character(0)) + } + + reporter <- teal.reporter::Reporter$new() + report_id <- jsonlite::read_json(file.path(report_dir, "Report.json"))$id + reporter$set_id(as.character(report_id)) + reporter$from_jsondir(report_dir) + + tables <- private$extract_tables_from_reporter(reporter) + if (length(tables) == 0) { + return(character(0)) + } + + if (!dir.exists(output_dir)) dir.create(output_dir, recursive = TRUE) + + written <- character(length(tables)) + for (i in seq_along(tables)) { + file_path <- file.path(output_dir, paste0(names(tables)[[i]], ".csv")) + utils::write.table(tables[[i]], file = file_path, sep = ",", col.names = FALSE, row.names = FALSE) + written[[i]] <- file_path + } + written + }, + #' Re-build reports #' @description #' Rebuild reports to include data that has changed. @@ -599,6 +635,62 @@ ReportManager <- R6::R6Class("ReportManager", # nolint: object_name_linter saveRDS(unname(code_list), file.path(path, "code.rds")) }, + #' Extract all table-type content from a Reporter as a named list of data.frames + extract_tables_from_reporter = function(reporter) { + cards <- reporter$get_cards() + result <- list() + for (card_i in seq_along(cards)) { + card <- cards[[card_i]] + card_name <- names(cards)[[card_i]] + card_title <- tryCatch( + teal.reporter::metadata(card)$title, + error = function(e) NULL + ) + if (is.null(card_title) || !nzchar(card_title)) card_title <- card_name + if (is.null(card_title) || !nzchar(card_title)) card_title <- paste0("card_", card_i) + safe_title <- gsub("[^[:alnum:]_-]", "_", card_title) + + # Collect content elements: ReportCard R6 uses $get_content(), teal_card S3 is a plain list + elements <- if (inherits(card, "ReportCard")) card$get_content() else as.list(card) + + tbl_count <- 0L + for (elem in elements) { + # Unwrap TableBlock (used by ReportCard$append_table) + if (inherits(elem, "TableBlock")) { + elem <- tryCatch(elem$get_content(), error = function(e) NULL) + } + df <- NULL + if (inherits(elem, c("TableTree", "ElementaryTable"))) { + df <- tryCatch( + { + as.data.frame( + rtables::matrix_form(elem)$strings, + stringsAsFactors = FALSE + ) + }, + error = function(e) NULL + ) + } else if (inherits(elem, "chunk_output")) { + df <- tryCatch(as.data.frame(elem[[1]]), error = function(e) NULL) + } else if (inherits(elem, "listing_df") || is.data.frame(elem)) { + df <- as.data.frame(elem) + } + if (!is.null(df)) { + tbl_count <- tbl_count + 1L + key <- if (tbl_count == 1L) safe_title else paste0(safe_title, "_", tbl_count) + # Deduplicate key across all cards using a numeric suffix + if (key %in% names(result)) { + n <- 2L + while (paste0(key, "_", n) %in% names(result)) n <- n + 1L + key <- paste0(key, "_", n) + } + result[[key]] <- df + } + } + } + result + }, + #' Register `onSessionEnded` to unlock report when session is closed register_cleanup = function() { if (!is.null(self$session)) { diff --git a/R/tm_report_manager.R b/R/tm_report_manager.R index 8b0eeaa..da34842 100644 --- a/R/tm_report_manager.R +++ b/R/tm_report_manager.R @@ -25,9 +25,8 @@ #' } #' @export tm_report_manager <- function( - reports_path = "reports", - auto_save = TRUE -) { + reports_path = "reports", + auto_save = TRUE) { module( ui = report_manager_ui, server = report_manager_server, @@ -92,6 +91,21 @@ report_manager_ui <- function(id) { var reportName = $(this).data('report'); Shiny.setInputValue('", ns("release_lock_click"), "', reportName, {priority: 'event'}); }); + $(document).on('click', '.csv-download-btn', function() { + var reportName = $(this).data('report'); + Shiny.setInputValue('", ns("csv_download_click"), "', reportName, {priority: 'event'}); + }); + Shiny.addCustomMessageHandler('trigger_csv_download', function(msg) { + var bytes = Uint8Array.from(atob(msg.b64), function(c) { return c.charCodeAt(0); }); + var blob = new Blob([bytes], {type: msg.type}); + var url = URL.createObjectURL(blob); + var a = document.createElement('a'); + a.href = url; + a.download = msg.filename; + document.body.appendChild(a); + a.click(); + setTimeout(function() { document.body.removeChild(a); URL.revokeObjectURL(url); }, 100); + }); "))) ) } @@ -103,7 +117,9 @@ report_manager_ui <- function(id) { #' @param reporter the object that holds the report. Provided by `teal`. #' @import shiny teal #' @importFrom DT renderDT +#' @importFrom jsonlite base64_enc #' @importFrom shinyjs toggleState disable enable +#' @importFrom utils write.csv #' @keywords internal report_manager_server <- function(id, reports_path = "reports", auto_save = TRUE, reporter) { moduleServer(id, function(input, output, session) { @@ -473,6 +489,27 @@ report_manager_server <- function(id, reports_path = "reports", auto_save = TRUE lock_icon <- '' } + # CSV download button (available for all reports that have saved content) + report_json <- file.path(rm$get_abs_report_path(report_name), "Report.json") + has_content <- file.exists(report_json) + safe_name <- gsub("'", "\\'", report_name) + csv_btn <- if (has_content) { + sprintf( + paste0( + '' + ), + safe_name + ) + } else { + paste0( + '' + ) + } + # Action buttons based on state if (is_active) { # Active report: can edit title, cannot delete, can rebuild @@ -535,7 +572,7 @@ report_manager_server <- function(id, reports_path = "reports", auto_save = TRUE } # Combine all elements with spacing - paste(lock_icon, edit_btn, load_btn, rebuild_btn, delete_btn, sep = " ") + paste(lock_icon, edit_btn, load_btn, rebuild_btn, delete_btn, csv_btn, sep = " ") }, character(1)) } else { df$actions <- character(0) @@ -856,6 +893,41 @@ report_manager_server <- function(id, reports_path = "reports", auto_save = TRUE }) }) + # CSV download click -> prepare files, encode as base64, push to browser + observeEvent(input$csv_download_click, { + report_name <- input$csv_download_click + tmp_dir <- file.path(tempdir(), paste0("csv_export_", gsub("[^[:alnum:]]", "_", report_name))) + written <- tryCatch( + rm$export_tables_to_csv(report_name, tmp_dir), + error = function(e) { + handle_error("exporting tables", e) + character(0) + } + ) + if (length(written) == 0) { + showNotification("No tables found in this report.", type = "warning") + return() + } + safe_title <- gsub("[^[:alnum:]_-]", "_", report_name) + if (length(written) == 1L) { + raw_bytes <- readBin(written[[1]], what = "raw", n = file.size(written[[1]])) + session$sendCustomMessage("trigger_csv_download", list( + b64 = jsonlite::base64_enc(raw_bytes), + filename = paste0(safe_title, "_tables.csv"), + type = "text/csv" + )) + } else { + zip_path <- file.path(tmp_dir, paste0(safe_title, "_tables.zip")) + zip::zip(zipfile = zip_path, files = written, mode = "cherry-pick") + raw_bytes <- readBin(zip_path, what = "raw", n = file.size(zip_path)) + session$sendCustomMessage("trigger_csv_download", list( + b64 = jsonlite::base64_enc(raw_bytes), + filename = paste0(safe_title, "_tables.zip"), + type = "application/zip" + )) + } + }) + # Insert Refresh button once onFlushed(function() { insertUI( diff --git a/man/ReportManager.Rd b/man/ReportManager.Rd index 8a74ae6..f3459ab 100644 --- a/man/ReportManager.Rd +++ b/man/ReportManager.Rd @@ -50,6 +50,7 @@ Initialize \code{ReportManager}} \item \href{#method-ReportManager-auto_save_observer}{\code{ReportManager$auto_save_observer()}} \item \href{#method-ReportManager-unlock_report_public}{\code{ReportManager$unlock_report_public()}} \item \href{#method-ReportManager-release_lock}{\code{ReportManager$release_lock()}} + \item \href{#method-ReportManager-export_tables_to_csv}{\code{ReportManager$export_tables_to_csv()}} \item \href{#method-ReportManager-rebuild_report}{\code{ReportManager$rebuild_report()}} \item \href{#method-ReportManager-clone}{\code{ReportManager$clone()}} } @@ -379,12 +380,39 @@ Release lock on current report (keep report loaded but make it read-only)} \if{html}{\out{
}} \describe{ \item{\code{report_title}}{(character) Title of the report to release lock from -Re-build reports} +Export all tables from a saved report as CSV files} } \if{html}{\out{
}} } } +\if{html}{\out{
}} +\if{html}{\out{}} +\if{latex}{\out{\hypertarget{method-ReportManager-export_tables_to_csv}{}}} +\subsection{\code{ReportManager$export_tables_to_csv()}}{ + Loads the report from disk, extracts all table-type content from each card, +converts each to a \code{data.frame} and writes it as a CSV file in \code{output_dir}. +Returns the vector of written file paths, or an empty character vector when +no tables are found. + \subsection{Usage}{ + \if{html}{\out{
}} + \preformatted{ReportManager$export_tables_to_csv(report_title, output_dir)} + \if{html}{\out{
}} + } + \subsection{Arguments}{ + \if{html}{\out{
}} + \describe{ + \item{\code{report_title}}{(character) Title of the report to export.} + \item{\code{output_dir}}{(character) Directory where CSV files will be written.} + } + \if{html}{\out{
}} + } + \subsection{Returns}{ + character vector of written file paths. +Re-build reports + } +} + \if{html}{\out{
}} \if{html}{\out{}} \if{latex}{\out{\hypertarget{method-ReportManager-rebuild_report}{}}} @@ -408,6 +436,7 @@ Lock report so that it can't be overwritten by another user Unlock report Save creator information Save code from each card as \code{code.rds} in the report directory. +Extract all table-type content from a Reporter as a named list of data.frames Register \code{onSessionEnded} to unlock report when session is closed} } \if{html}{\out{}} From 0fa915a94add15ecc133d26a2b8f619eb985b431 Mon Sep 17 00:00:00 2001 From: pposiada Date: Wed, 10 Jun 2026 08:03:09 +0000 Subject: [PATCH 02/10] #46 Add NEWS --- NEWS.md | 1 + 1 file changed, 1 insertion(+) diff --git a/NEWS.md b/NEWS.md index 0114b6b..cee6d48 100644 --- a/NEWS.md +++ b/NEWS.md @@ -6,6 +6,7 @@ - Fixed ID conflict in `or_filtering_transformator` that caused errors when multiple instances were used in the same Shiny app. Removed a `shinyBS::bsModal()` block with fixed, non-module-scoped IDs (dead code — preview is handled by `shiny::showModal()`), resolving duplicate element IDs across instances. - Added `updateOn = "blur"` to all `textInput` controls so that reactive updates are only triggered when the user leaves the field, reducing unnecessary re-renders while typing. Requires `shiny >= 1.11.0`. - Refactored `title_footer_decorator` not to overwrite the first row of the TABLE.ID column in the imported file. This change allows for importing files that have meaningful data in the first row. +- Added a button for CSV download for `tm_report_manager` module. #46 # Version 0.0.3 From 4c10cae2ee18cebe7dd2e365a3f7d8e0e02821de Mon Sep 17 00:00:00 2001 From: github-actions <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 10 Jun 2026 08:05:27 +0000 Subject: [PATCH 03/10] [skip style] [skip vbump] Restyle files --- R/tm_report_manager.R | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/R/tm_report_manager.R b/R/tm_report_manager.R index da34842..f1e63b3 100644 --- a/R/tm_report_manager.R +++ b/R/tm_report_manager.R @@ -25,8 +25,9 @@ #' } #' @export tm_report_manager <- function( - reports_path = "reports", - auto_save = TRUE) { + reports_path = "reports", + auto_save = TRUE +) { module( ui = report_manager_ui, server = report_manager_server, From 7f8adadd219e751f20104575392aff413c7e146c Mon Sep 17 00:00:00 2001 From: pposiada Date: Thu, 25 Jun 2026 14:26:21 +0000 Subject: [PATCH 04/10] #46 Move method to ReportManager class --- R/ReportManager.R | 43 ++++++++++++++++++++++++++++++++++++ R/tm_report_manager.R | 51 ++----------------------------------------- 2 files changed, 45 insertions(+), 49 deletions(-) diff --git a/R/ReportManager.R b/R/ReportManager.R index 74d8417..2347799 100644 --- a/R/ReportManager.R +++ b/R/ReportManager.R @@ -484,6 +484,49 @@ ReportManager <- R6::R6Class("ReportManager", # nolint: object_name_linter } }, + #' Set up CSV download observer + #' @description + #' Registers the `observeEvent` that handles CSV download clicks. + #' Call once from `moduleServer` after initialisation. + #' @param input Shiny input object from `moduleServer` + setup_csv_download = function(input) { + shiny::observeEvent(input$csv_download_click, { + report_name <- input$csv_download_click + tmp_dir <- file.path(tempdir(), paste0("csv_export_", gsub("[^[:alnum:]]", "_", report_name))) + written <- tryCatch( + self$export_tables_to_csv(report_name, tmp_dir), + error = function(e) { + shiny::showNotification( + sprintf("Error exporting tables: %s", conditionMessage(e)), type = "error" + ) + character(0) + } + ) + if (length(written) == 0) { + shiny::showNotification("No tables found in this report.", type = "warning") + return() + } + safe_title <- gsub("[^[:alnum:]_-]", "_", report_name) + if (length(written) == 1L) { + raw_bytes <- readBin(written[[1]], what = "raw", n = file.size(written[[1]])) + self$session$sendCustomMessage("trigger_csv_download", list( + b64 = jsonlite::base64_enc(raw_bytes), + filename = paste0(safe_title, "_tables.csv"), + type = "text/csv" + )) + } else { + zip_path <- file.path(tmp_dir, paste0(safe_title, "_tables.zip")) + zip::zip(zipfile = zip_path, files = written, mode = "cherry-pick") + raw_bytes <- readBin(zip_path, what = "raw", n = file.size(zip_path)) + self$session$sendCustomMessage("trigger_csv_download", list( + b64 = jsonlite::base64_enc(raw_bytes), + filename = paste0(safe_title, "_tables.zip"), + type = "application/zip" + )) + } + }) + }, + #' Export all tables from a saved report as CSV files #' @description #' Loads the report from disk, extracts all table-type content from each card, diff --git a/R/tm_report_manager.R b/R/tm_report_manager.R index da34842..16ea3ea 100644 --- a/R/tm_report_manager.R +++ b/R/tm_report_manager.R @@ -91,10 +91,6 @@ report_manager_ui <- function(id) { var reportName = $(this).data('report'); Shiny.setInputValue('", ns("release_lock_click"), "', reportName, {priority: 'event'}); }); - $(document).on('click', '.csv-download-btn', function() { - var reportName = $(this).data('report'); - Shiny.setInputValue('", ns("csv_download_click"), "', reportName, {priority: 'event'}); - }); Shiny.addCustomMessageHandler('trigger_csv_download', function(msg) { var bytes = Uint8Array.from(atob(msg.b64), function(c) { return c.charCodeAt(0); }); var blob = new Blob([bytes], {type: msg.type}); @@ -117,7 +113,6 @@ report_manager_ui <- function(id) { #' @param reporter the object that holds the report. Provided by `teal`. #' @import shiny teal #' @importFrom DT renderDT -#' @importFrom jsonlite base64_enc #' @importFrom shinyjs toggleState disable enable #' @importFrom utils write.csv #' @keywords internal @@ -130,6 +125,7 @@ report_manager_server <- function(id, reports_path = "reports", auto_save = TRUE # Initialize report manager object rm <- ReportManager$new(reports_path = reports_path, session) + rm$setup_csv_download(input) if (auto_save) { rm$auto_save_observer(reporter) } @@ -492,16 +488,8 @@ report_manager_server <- function(id, reports_path = "reports", auto_save = TRUE # CSV download button (available for all reports that have saved content) report_json <- file.path(rm$get_abs_report_path(report_name), "Report.json") has_content <- file.exists(report_json) - safe_name <- gsub("'", "\\'", report_name) csv_btn <- if (has_content) { - sprintf( - paste0( - '' - ), - safe_name - ) + create_action_button("csv_download_click", report_name, "download", "btn-default", "Download tables as CSV") } else { paste0( '