Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Feature download csv #13

Merged
merged 30 commits into from
Feb 18, 2025
Merged

Feature download csv #13

merged 30 commits into from
Feb 18, 2025

Conversation

yashshah1995
Copy link

@yashshah1995 yashshah1995 commented Jan 20, 2025

Implement download button for events table
Percentage and number should be in separate columns
A disclaimer should be presented before the download letting the user know it is a unvalidated output (follow dv.listings strategy)

@yashshah1995 yashshah1995 self-assigned this Jan 20, 2025
@yashshah1995 yashshah1995 requested a review from a team as a code owner January 20, 2025 15:53
@yashshah1995 yashshah1995 removed the request for review from a team January 20, 2025 15:58
Copy link

@iglauss iglauss left a comment

Choose a reason for hiding this comment

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

I did not review the entire PR code-wise, but tested the mock app. A few things which I noticed:

  1. The old checkbox label should be removed. You can do it as in dv.listings where only "I agree to the following:" is printed and the mod function contains an argument intended_use_label = "Use only for internal review and monitoring during the conduct of clinical trials.", which will be pasted to the first string.
  2. Only the first level gets downloaded. Could we change this to download the entire table?
  3. I'm not sure if commas as separators are a good choice, at least in the mock app, the download gets a bit messy, because AEBODYSYS contains commas as well.
  4. There are no column headers in the exported file.

@yashshah1995
Copy link
Author

@iglauss thanks for the review. The requested features are implemented.

For 3., it now downloads as excel

For 4., Because we separate columns into two (N and per), Adding Overall Patients to column header was messy. So they are added as a first row in the dataframe.

Comment on lines 132 to 153
download_enable <- shiny::eventReactive(
c(input[[EXP$ID$FILENAME_BOX]], input[[EXP$ID$DATAPROTECT_BOX]]),
{
if (
(input[[EXP$ID$FILENAME_BOX]] != "") &
input[[EXP$ID$DATAPROTECT_BOX]]
) { # nolint end
return(TRUE)
} else {
return(FALSE)
}
}
)

# Enable/disable download button separately (to allow testing of logic)
shiny::observeEvent(download_enable(), {
if (download_enable()) {
shinyjs::enable(EXP$ID$DOWNLOAD_BUTTON)
} else {
shinyjs::disable(EXP$ID$DOWNLOAD_BUTTON)
}
})
Copy link
Contributor

Choose a reason for hiding this comment

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

Can we compact these lines in a single observe? download_enable is not used anywhere else as far as I can see

Copy link
Author

Choose a reason for hiding this comment

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

Combined in one Observe


# If there is no dataset being displayed
if (is.null(dataset)) {
shinyFeedback::showToast(
Copy link
Contributor

Choose a reason for hiding this comment

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

Do we need shinyFeedback in this case? I would rather use shiny::validate to avoid an extra dependency. Or the native showNotification in shiny as we are inside an obvserve.

Copy link
Author

Choose a reason for hiding this comment

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

Replaced with shiny::validate

Comment on lines 169 to 196
# Get col names and total patients
total_colname <- dataset[["meta"]]$n_denominator

#Get event variables
event_vars <- dataset[["meta"]]$hierarchy

excelfile <- dataset[["df"]] |>
dplyr::select(
event_vars,
names(total_colname)
) |>
dplyr::mutate(dplyr::across(
dplyr::where(is.list),
~ purrr::map_chr(., "count")
)) |>
dplyr::mutate(dplyr::across(
dplyr::all_of(names(total_colname)),
~ gsub("\u2014", NA, .)
)) |>
dplyr::mutate(dplyr::across(
dplyr::all_of(event_vars),
~ gsub("\035", "Total", .)
))

excelfile <- add_total_patient(
excelfile,
total_colname
)
Copy link
Contributor

Choose a reason for hiding this comment

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

I would extract this in a separate function, and test is in a non reactive environment.

Maybe with the following structure

count_table <- call_table_computing_function(xxx)
call_data_extraction(count_table)

This way if we change the way we count in the table we break the test and remember the coupling between both of them

Comment on lines 1 to 49
#' Function to separate a column
#'
#' @details
#' Columns where `(` is encountered is split into two and new columns
#' are mutated
#'
#' @param df dataframe
#' @param col column name
#'
#' @return dataframe with new mutated separated columns
#' @keywords internal
separate_column <- function(df, col) {
df |>
tidyr::separate(col, into = paste0(col, c("_N", "_per")),
sep = " \\(",
remove = TRUE,
fill = "right") %>%
dplyr::mutate(dplyr::across(dplyr::ends_with("_per"), ~stringr::str_remove(., "\\)")))
}


#' Adds Overall number of patients
#'
#' @details
#' Extracts overall number of patient (html table output column headers)
#' from metadata, adds it to first row of the downloaded table.
#'
#'
#' @param df dataframe
#' @param total_colname named int, col names with overall patients
#'
#' @return dataframe with additional row containing overall patients
#'
#' @keywords internal
add_total_patient <- function(df, total_colname) {

# empty row with column name placeholder
new_row <- setNames(data.frame(matrix(ncol = ncol(df), nrow = 1)), names(df))

new_row[[1, 1]] <- "Overall No. of Patients"

# match col names to add total patients
matched_cols <- match(names(total_colname), names(new_row))
new_row[1, matched_cols] <- total_colname

return(rbind(new_row, df))


}
Copy link
Contributor

Choose a reason for hiding this comment

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

I would compact this in a single function together with the code that is now inside the download handler.

Copy link
Author

Choose a reason for hiding this comment

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

Put different export utils (and the dplyr operation from mod into main function).

Copy link

@iglauss iglauss left a comment

Choose a reason for hiding this comment

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

Thanks, Yash! I'm almost completely happy. Just one remaining issue: When I add a minimum % >0, this is not reflected in the PDF, right? Could we change this?

DESCRIPTION Outdated
Config/testthat/edition: 3
Config/testthat/parallel: false
Imports: shiny (>= 1.7.1),dplyr (>= 1.0.7), purrr (>= 0.3.4),
tidyr (>= 1.1.4),
rlang, checkmate (>= 2.0.0), htmltools,
stats, pharmaverseadam
stats, pharmaverseadam, writexl
Copy link

Choose a reason for hiding this comment

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

Just to make sure that we do not add unnecessary independencies: Could we use openxlsx instead of writexl? Becuase dv.listings is already using openxlsx. If not, this is also fine. Just want to question it.

Copy link
Author

Choose a reason for hiding this comment

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

Thanks for pointing out previous comment, Mistake in passing non-reactive object. Fixed!
Replaced with openxlsx

@@ -473,7 +475,8 @@ hierarchical_count_table_server <- function(
subjid_var,
show_modal_on_click = FALSE,
default_hierarchy = NULL,
default_group = NULL) {
default_group = NULL,
intended_use_label = NULL) {
Copy link

Choose a reason for hiding this comment

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

We still need to add the intended_use_label in the roxygen docu above

Copy link
Contributor

@ml-ebs-ext ml-ebs-ext left a comment

Choose a reason for hiding this comment

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

Here's a bit of feedback. Thanks for the good work!

remove = TRUE,
fill = "right"
) %>%
dplyr::mutate(dplyr::across(dplyr::ends_with("_per"), ~ stringr::str_remove(., "\\)")))
Copy link
Contributor

Choose a reason for hiding this comment

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

Since this is the only use of stringr in the whole package, we could rely instead on base::sub and cut a dependency:

Suggested change
dplyr::mutate(dplyr::across(dplyr::ends_with("_per"), ~ stringr::str_remove(., "\\)")))
dplyr::mutate(dplyr::across(dplyr::ends_with("_per"), ~ sub(")", "", . )))

DESCRIPTION Outdated
@@ -13,13 +13,14 @@ LazyData: true
Roxygen: list(markdown = TRUE)
RoxygenNote: 7.3.1
Suggests: dv.manager (>= 2.1.4), jsonlite, rmarkdown, testthat (>=
3.0.0), shinytest2, devtools, knitr, tibble, utils
3.0.0), shinytest2, devtools, knitr, tibble, utils,
shinyjs, stringr
Copy link
Contributor

Choose a reason for hiding this comment

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

  • There is a solitary call to stringr::str_remove which I've suggested to replace with base::sub. If you think that's OK, then we could drop stringr.
  • I believe both stringr (if we were to keep it) and shinyjs should be under Imports instead of under Suggests. Reasoning.

# check validity of parameters


checkmate::assert(
Copy link
Contributor

Choose a reason for hiding this comment

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

When an assertion triggers inside an observer, it kills the app.
We could instead either:

  • shiny::req() the checks, which would prevent the crash but fail silently. Probably would leave users scratching their heads.
  • Use a shiny::showNotification similar like the one you have below describing the problem to the user.

Copy link
Author

@yashshah1995 yashshah1995 Feb 17, 2025

Choose a reason for hiding this comment

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

Hi @ml-ebs-ext thanks for the comment. Just to make a case for the checkmate assertion into the observer; Wouldn't it be better to let the app gracefully crash, as the intended person for this check would be app creator rather than app user? Since this way they would know something is wrong in the dataset before publishing.

Copy link
Contributor

Choose a reason for hiding this comment

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

Letting the app creator know something is wrong is perfectly OK, but

gracefully crash

sounds like an oxymoron to me 😋.

Also, in this case app creators need to press the export button in order to trigger the error. So, if they forget to do it, it's the final user that gets the crash and no message.

Still, I see there's only one caller of the export_counttable module and it already performs some checks which happen without user interaction, so I'll leave it to you to judge the best course of action here.

Copy link
Author

Choose a reason for hiding this comment

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

Good point, I replaced it with simple if and showNotifications.

Comment on lines 135 to 143
# Check if the inputs exist
if (!is.null(input[[EXP$ID$FILENAME_BOX]]) && !is.null(input[[EXP$ID$DATAPROTECT_BOX]])) {
# Check if the conditions are met
if (input[[EXP$ID$FILENAME_BOX]] != "" && input[[EXP$ID$DATAPROTECT_BOX]]) {
shinyjs::enable(EXP$ID$DOWNLOAD_BUTTON)
} else {
shinyjs::disable(EXP$ID$DOWNLOAD_BUTTON)
}
}
Copy link
Contributor

Choose a reason for hiding this comment

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

This is mostly a stylistic suggestion. The only thing of value is the rejection of invalid destination file names. The rest, you can ignore:

Suggested change
# Check if the inputs exist
if (!is.null(input[[EXP$ID$FILENAME_BOX]]) && !is.null(input[[EXP$ID$DATAPROTECT_BOX]])) {
# Check if the conditions are met
if (input[[EXP$ID$FILENAME_BOX]] != "" && input[[EXP$ID$DATAPROTECT_BOX]]) {
shinyjs::enable(EXP$ID$DOWNLOAD_BUTTON)
} else {
shinyjs::disable(EXP$ID$DOWNLOAD_BUTTON)
}
}
sanitized_fname <- fs::path_sanitize(input[[EXP$ID$FILENAME_BOX]])
enable_button <- (nchar(sanitized_fname) > 0) && isTRUE(input[[EXP$ID$DATAPROTECT_BOX]])
shinyjs::toggleState(id = EXP$ID$DOWNLOAD_BUTTON, condition = enable_button)

# Download
output[[EXP$ID$DOWNLOAD_BUTTON]] <- shiny::downloadHandler(
filename = function() {
paste0(input[[EXP$ID$FILENAME_BOX]], ".xlsx")
Copy link
Contributor

Choose a reason for hiding this comment

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

Avoids illegal destination file names:

Suggested change
paste0(input[[EXP$ID$FILENAME_BOX]], ".xlsx")
sanitized_fname <- fs::path_sanitize(input[[EXP$ID$FILENAME_BOX]])
paste0(sanitized_fname, ".xlsx")

@yashshah1995 yashshah1995 merged commit c1cee1d into dev Feb 18, 2025
5 checks passed
@yashshah1995 yashshah1995 deleted the feature_download_csv branch February 18, 2025 13:27
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
enhancement New feature or request
Projects
None yet
Development

Successfully merging this pull request may close these issues.

[feature] As a user I want to download the AE hierarchy table as a CSV, or other editable file
4 participants