Skip to content
6 changes: 4 additions & 2 deletions DESCRIPTION
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ Imports:
ggplot2,
ggplotify,
gridify,
jsonlite,
junco,
methods,
openxlsx,
Expand All @@ -49,16 +50,17 @@ Imports:
teal,
teal.code,
teal.modules.clinical,
teal.reporter,
tern,
yaml
yaml,
zip
Suggests:
forcats,
knitr,
rAccess,
rmarkdown,
teal.data,
teal.modules.general,
teal.reporter,
teal.transform
VignetteBuilder: knitr
Config/Needs/website: insightsengineering/nesttemplate
Expand Down
1 change: 1 addition & 0 deletions NAMESPACE
Original file line number Diff line number Diff line change
Expand Up @@ -103,4 +103,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)
1 change: 1 addition & 0 deletions NEWS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
136 changes: 136 additions & 0 deletions R/ReportManager.R
Original file line number Diff line number Diff line change
Expand Up @@ -484,6 +484,86 @@ 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 initialization.
#' @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,
#' 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.
Expand Down Expand Up @@ -599,6 +679,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)) {
Expand Down
28 changes: 27 additions & 1 deletion R/tm_report_manager.R
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,17 @@ report_manager_ui <- function(id) {
var reportName = $(this).data('report');
Shiny.setInputValue('", ns("release_lock_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);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

shall we use a normal shiny file downloader?

});
")))
)
}
Expand All @@ -104,6 +115,7 @@ report_manager_ui <- function(id) {
#' @import shiny teal
#' @importFrom DT renderDT
#' @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) {
Expand All @@ -114,6 +126,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)
}
Expand Down Expand Up @@ -473,6 +486,19 @@ report_manager_server <- function(id, reports_path = "reports", auto_save = TRUE
lock_icon <- '<i class="fa fa-unlock" style="color: green; margin-right: 8px;" title="Unlocked"></i>'
}

# 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)
csv_btn <- if (has_content) {
create_action_button("csv_download_click", report_name, "download", "btn-default", "Download tables as CSV")
} else {
paste0(
'<button type="button" class="btn btn-sm btn-default" ',
'disabled title="No content to export" data-toggle="tooltip">',
'<i class="fa fa-download"></i></button>'
)
}

# Action buttons based on state
if (is_active) {
# Active report: can edit title, cannot delete, can rebuild
Expand Down Expand Up @@ -535,7 +561,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)
Expand Down
53 changes: 52 additions & 1 deletion man/ReportManager.Rd

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading