Skip to content
1 change: 1 addition & 0 deletions DESCRIPTION
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ Imports:
bslib (>= 0.8.0),
checkmate (>= 2.1.0),
colourpicker (>= 1.3.0),
crane,
dplyr (>= 1.1.0),
DT (>= 0.13),
forcats (>= 1.0.0),
Expand Down
2 changes: 2 additions & 0 deletions NAMESPACE
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ export(tm_g_distribution)
export(tm_g_response)
export(tm_g_scatterplot)
export(tm_g_scatterplotmatrix)
export(tm_gt_tbl_summary)
export(tm_missing_data)
export(tm_outliers)
export(tm_rmarkdown)
Expand All @@ -36,3 +37,4 @@ import(teal.transform)
importFrom(dplyr,"%>%")
importFrom(dplyr,.data)
importFrom(lifecycle,deprecated)
importFrom(methods,is)
339 changes: 339 additions & 0 deletions R/tm_gt_tbl_summary.R
Original file line number Diff line number Diff line change
@@ -0,0 +1,339 @@
#' `teal` module: Summary table
#'
#' Generates a table summary from a dataset using gtsummary.
#'
#' @inheritParams teal::module
#' @inheritParams shared_params
#' @param by (`data_extract_spec` or `list` of multiple `data_extract_spec`)
#' Object with all available choices with pre-selected option for how to split the rows
#'
#' `data_extract_spec` must not allow multiple selection.
#' @param include (`data_extract_spec` or `list` of multiple `data_extract_spec`)
#' Object with all available choices with pre-selected option for which columns to include as rows.
#'
#' `data_extract_spec` can allow multiple selection in this case.
#' @param col_label Used to override default labels in summary table, e.g. `list(age = "Age, years")`.
#' The default for each variable is the column label attribute, `attr(., 'label')`.
#' If no label has been set, the column name is used.
#' @inheritParams gtsummary::tbl_summary
#' @inherit shared_params return
#' @param missing_text String indicating text shown on missing row.
#' @param missing_stat statistic to show on missing row. Default is `"{N_miss}"`.
#' Possible values are `N_miss`, `N_obs`, `N_nonmiss`, `p_miss`, `p_nonmiss`.
#' @inheritSection gtsummary::tbl_summary statistic argument
#' @inheritSection gtsummary::tbl_summary digits argument
#' @inheritSection gtsummary::tbl_summary type and value arguments
#' @section Decorating Module:
#'
#' This module generates the following objects, which can be modified in place using decorators:
#' - `table` (`gtsummary` - output of [`crane::tbl_roche_summary()`])
#'
#' A Decorator is applied to the specific output using a named list of `teal_transform_module` objects.
#' The name of this list corresponds to the name of the output to which the decorator is applied.
#' See code snippet below:
#'
#' ```
#' tm_gt_tbl_summary(
#' ..., # arguments for module
#' decorators = list(
#' table = teal_transform_module(...) # applied to the `table` output
#' )
#' )
#' ```
#' For additional details and examples of decorators, refer to the vignette
#' `vignette("decorate-module-output", package = "teal.modules.general")`.
#'
#' To learn more please refer to the vignette
#' `vignette("transform-module-output", package = "teal")` or the [`teal::teal_transform_module()`] documentation.
#'
#' @inheritSection teal::example_module Reporting
#' @export
#' @importFrom methods is
#' @examples
#' data <- within(teal_data(), {
#' ADSL <- teal.data::rADSL
#' })
#' join_keys(data) <- default_cdisc_join_keys[names(data)]
#' app <- init(
#' data = data,
#' modules = modules(
#' tm_gt_tbl_summary(
#' by = data_extract_spec(
#' dataname = "ADSL",
#' select = select_spec(
#' choices = c("SEX", "COUNTRY", "SITEID", "ACTARM"),
#' selected = "SEX",
#' multiple = FALSE
#' )
#' ),
#' include = data_extract_spec(
#' dataname = "ADSL",
#' select = select_spec(
#' choices = c("SITEID", "COUNTRY", "ACTARM"),
#' selected = "SITEID",
#' multiple = TRUE,
#' fixed = FALSE
#' )
#' )
#' )
#' )
#' )
#' if (interactive()) {
#' shinyApp(app$ui, app$server)
#' }
tm_gt_tbl_summary <- function(
label = "Table summary",
by = NULL,
col_label = NULL,
statistic = list(
gtsummary::all_continuous() ~ c("{mean} ({sd})", "{median}", "{min} - {max}"),
gtsummary::all_categorical() ~ "{n} ({p}%)"
),
digits = NULL,
type = NULL,
value = NULL,
missing_text = "<Missing>",
missing_stat = "{N_nonmiss}",
sort = gtsummary::all_categorical(FALSE) ~ "alphanumeric",
include = tidyselect::everything(),
Copy link
Copy Markdown
Contributor

@gogonzo gogonzo Dec 15, 2025

Choose a reason for hiding this comment

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

This won't work. I'm sceptical if tidyselect::everything can be used with data_extract_spec. tidyselect::everything() fails when forced (executed outside of tidyselect::eval_tidy). Referring to the comment I think that if picks succeeds and will be implemented this will have to be deprecated and changed to picks.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Yes, the default arguments won't work. I just wanted to have here to make it easier for users to match the arguments of the gtsummary function to the module ones. I'll change it.

My comment about picks is now what is accepted and what is not accepted but about what is the R code shown the users. If a users use: picks(variables(where(is.numeric))) but we evaluate that and replace by the name of the variables ("AGE", ...) , the "Show R code" won't show that the app developer set the selection to the numeric values. It will only show the variables selected: include = where(is.numeric) vs include = c(AGE, ...). There is some information lost there that could be relevant.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Yes, unfortunately original parameter will be lost. I don't think we can preserve tidyselect specs to use them in the final visualization.
Let's explore this more with picks when it will be reviewed more seriously 👍

transformators = list(),
decorators = list()
) {
message("Initializing tm_gt_tbl_summary")
checkmate::assert_string(label)
if (inherits(by, "data_extract_spec")) {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

If you want to use tidyselect and assert on this argument, then you need to handle this in a specific way. See here:
https://github.com/insightsengineering/teal.transform/blob/621603df655f4c051de612f1c35a66cf44a81756/R/0-picks.R#L427

checkmate::assert_list(list(by), types = "data_extract_spec", null.ok = TRUE, any.missing = FALSE, all.missing = FALSE)

Check warning on line 105 in R/tm_gt_tbl_summary.R

View workflow job for this annotation

GitHub Actions / SuperLinter 🦸‍♀️ / Lint R code 🧶

file=R/tm_gt_tbl_summary.R,line=105,col=121,[line_length_linter] Lines should not be more than 120 characters. This line is 123 characters.
assert_single_selection(list(by))
}
if (inherits(include, "data_extract_spec")) {
checkmate::assert_list(list(include), types = "data_extract_spec", any.missing = FALSE, all.missing = FALSE)
}
assert_decorators(decorators, "table")

# Make UI args
args <- as.list(environment())

module <- module(
label = label,
server = srv_gt_tbl_summary,
ui = ui_gt_tbl_summary,
ui_args = args,
server_args = list(
by = by,
col_label = col_label,
statistic = statistic,
digits = digits,
type = type,
value = value,
# missing = missing,

Check warning on line 128 in R/tm_gt_tbl_summary.R

View workflow job for this annotation

GitHub Actions / SuperLinter 🦸‍♀️ / Lint R code 🧶

file=R/tm_gt_tbl_summary.R,line=128,col=9,[commented_code_linter] Remove commented code.
missing_text = missing_text,
missing_stat = missing_stat,
sort = sort,
# percent = percent,

Check warning on line 132 in R/tm_gt_tbl_summary.R

View workflow job for this annotation

GitHub Actions / SuperLinter 🦸‍♀️ / Lint R code 🧶

file=R/tm_gt_tbl_summary.R,line=132,col=9,[commented_code_linter] Remove commented code.
include = include,
decorators = decorators
),
transformators = transformators
)
attr(module, "teal_bookmarkable") <- TRUE
module
}


ui_gt_tbl_summary <- function(id, ...) {
ns <- NS(id)
args <- list(...)
teal.widgets::standard_layout(
output = gt::gt_output(ns("table")),
encoding = tags$div(
tags$label("Encodings", class = "text-primary"),
teal.transform::datanames_input(args[c("by", "include")]),
teal.transform::data_extract_ui(ns("by"),
label = "Variable(s) to stratify with",
data_extract_spec = args$by
),
teal.transform::data_extract_ui(ns("include"),
label = "Variable(s) to include",
data_extract_spec = args$include
),
radioButtons(
ns("missing"),
label = "Display missing counts:",
choices = c("No" = "no", "If any" = "ifany", "Always" = "always"),
selected = "no"
),
radioButtons(
ns("percent"),
label = "Percentage based on:",
choices = c("Column" = "column", "Row" = "row", "Cell" = "cell"),
selected = "column"
),
ui_decorate_teal_data(ns("decorator"), decorators = select_decorators(args$decorators, "table"))
),
pre_output = args$pre_output,
post_output = args$post_output
)
}

srv_gt_tbl_summary <- function(id,
data,
by,
col_label,
statistic,
digits,
type,
value,
missing_text,
missing_stat,
sort,
include,
decorators) {
checkmate::assert_class(data, "reactive")
checkmate::assert_class(isolate(data()), "teal_data")
checkmate::assert_character(missing_text, len = 1L)
checkmate::assert_character(missing_stat, len = 1L)

moduleServer(id, function(input, output, session) {
teal.logger::log_shiny_input_changes(input, namespace = "teal.modules.general")


qenv <- reactive({
obj <- req(data())
teal.reporter::teal_card(obj) <-
c(
teal.reporter::teal_card(obj),
teal.reporter::teal_card("## Module's output(s)")
)
teal.code::eval_code(obj, "library(crane)")
})

summary_args <- reactive({
req(qenv())

# table
if (!is.null(by) || !is.null(include)) {
validate(need(is_single_dataset(list(by = by, include = include)), "Variables should come from the same dataset."))

Check warning on line 215 in R/tm_gt_tbl_summary.R

View workflow job for this annotation

GitHub Actions / SuperLinter 🦸‍♀️ / Lint R code 🧶

file=R/tm_gt_tbl_summary.R,line=215,col=121,[line_length_linter] Lines should not be more than 120 characters. This line is 123 characters.
}

dataset <- if (!is.null(by)) {
by$dataname
} else {
include$dataname
}

validate(
need(!is.null(dataset), "Specify variables to stratify or to include on the summary table."),
need(teal.transform::is_single_dataset(by, include), "Input from multiple tables: this module doesn't accept that.")

Check warning on line 226 in R/tm_gt_tbl_summary.R

View workflow job for this annotation

GitHub Actions / SuperLinter 🦸‍♀️ / Lint R code 🧶

file=R/tm_gt_tbl_summary.R,line=226,col=121,[line_length_linter] Lines should not be more than 120 characters. This line is 124 characters.
)

nam_input <- names(input)

# by: input + corner cases
if (!is.null(by)) {
by_variable <- input[[nam_input[startsWith(nam_input, "by") & endsWith(nam_input, "select")]]]
}

# label columns
if (!is.null(col_label)) {
checkmate::check_character(col_label)
}

# statistic
if (!is.null(statistic)) {
validate(need(all(vapply(statistic, is, class2 = "formula", logical(1L))), "All elements of statistic should be formulas"))

Check warning on line 243 in R/tm_gt_tbl_summary.R

View workflow job for this annotation

GitHub Actions / SuperLinter 🦸‍♀️ / Lint R code 🧶

file=R/tm_gt_tbl_summary.R,line=243,col=121,[line_length_linter] Lines should not be more than 120 characters. This line is 131 characters.
}

# digits
if (!is.null(digits)) {
integer <- is.integer(digits) && length(digits) >= 1L
functions <- is.function(digits) || all(vapply(digits, is.function, logical(1L)))
validate(need(any(integer || functions), "digits should be integer(s) or a function (or list of)."))
}
# type
if (!is.null(type)) {
possible_types <- c("continuous", "continuous2", "categorical", "dichotomous")
validate(need(
length(type) == 1L && type %in% possible_types,
paste0("One of: c(", toString(dQuote(possible_types)), ").")
))
}

# value
if (!is.null(type)) {
possible_types <- c("continuous", "continuous2", "categorical", "dichotomous")
validate(need(
length(type) == 1L && type %in% possible_types,
paste0("One of: c(", toString(dQuote(possible_types)), ").")
))
}

# nonmissing: input

# nonmissing_text
if (!identical(missing_text, "<Missing>")) {
validate(need(is.character(missing_text), "Must be a character."))
}

# nonmissing_stat
if (!identical(missing_stat, "{N_miss}")) {
validate(need(is.character(missing_stat), "Must be a character to be parsed by glue."))
}

# sort
if (!is.null(sort)) {
validate(need(all(vapply(sort, is, class2 = "formula", logical(1L))), "All elements of sort should be formulas"))

Check warning on line 284 in R/tm_gt_tbl_summary.R

View workflow job for this annotation

GitHub Actions / SuperLinter 🦸‍♀️ / Lint R code 🧶

file=R/tm_gt_tbl_summary.R,line=284,col=121,[line_length_linter] Lines should not be more than 120 characters. This line is 121 characters.
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Suggested change
validate(need(all(vapply(sort, is, class2 = "formula", logical(1L))), "All elements of sort should be formulas"))
validate(need(all(vapply(list(sort), is, class2 = "formula", logical(1L))), "All elements of sort should be formulas"))

sort has to be list since we're running it against vapply.

Otherwise, I got the validation:

image

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Did you use sort = all_categorical(FALSE) ~ "alphanumeric"? I should change a bit the validation to account for that case. The proposed change I think wouldn't work for sort = list(all_categorical(FALSE) ~ "alphanumeric", all_numeric(TRUE) ~ "alphanumeric") (pseudocode to show how I thought this parameter would be used).

}
# percent: input
# include: input + corner cases
include_variables <- input[[nam_input[startsWith(nam_input, "include") & endsWith(nam_input, "select")]]]
if (is.null(include_variables)) {
include_variables <- formals(tbl_summary)$include
}

call("tbl_roche_summary",
data = as.name(dataset),
by = by_variable,
label = col_label,
statistic = statistic,
digits = digits,
type = type,
value = value,
nonmissing = input$missing,
nonmissing_text = missing_text,
nonmissing_stat = missing_stat,
sort = sort,
percent = input$percent,
include = include_variables
)
})

output_q <- reactive({
q <- req(qenv())
table_call <- req(summary_args())
within(q,
expr = {
table <- table_crane
},
table_crane = table_call
)
})

decorated_output_q <- srv_decorate_teal_data(
id = "decorator",
data = output_q,
decorators = select_decorators(decorators, "table"),
expr = quote(table)
)

table_r <- reactive({
req(decorated_output_q())
table <- decorated_output_q()[["table"]]
gtsummary::as_gt(table)
})
output$table <- gt::render_gt({
gtsummary::as_gt(output_q()[["table"]])
})

decorated_output_q
})
}
2 changes: 2 additions & 0 deletions inst/WORDLIST
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,10 @@ facetting
funder
ggmosaic
ggplot
gtsummary
pre
qq
reportable
sortable
tabset
tbl
Loading
Loading