Skip to content

Adds a module to support gtsummary#973

Merged
llrs-roche merged 32 commits intomainfrom
tm_gtsummary
Feb 9, 2026
Merged

Adds a module to support gtsummary#973
llrs-roche merged 32 commits intomainfrom
tm_gtsummary

Conversation

@llrs-roche
Copy link
Copy Markdown
Contributor

@llrs-roche llrs-roche commented Jan 30, 2026

Fixes https://github.com/insightsengineering/coredev-tasks/issues/714
Related to https://github.com/insightsengineering/coredev-tasks/issues/676

Adds 3 more package dependencies: (methods), crane, gtsummary, gt. I haven't added minimal version for them.
We could remove crane dependency at the cost of having less corporate style tables (instead of using crane::tbl_roche_summary() we could have the vanilla gtsummary::tbl_summary().

Documentation is mostly imported from other packages.

It involves just 3 functions: the module (is this a good name for such module?), the ui and the server function.
Support to download the table depends on insightsengineering/teal.widgets#335.

Test will come once this branch is reviewed, but known corner case from the module: No include variables should select all variables except the one used to split the columns by.

Further modifications of the table should be done via decorators not included on this PR.
Adding decorators might involve modifying check_decorators() to handle more than one decorator for the same object. This function is duplicated on TMG and TMC and I haven't included on the PR but here is the one I was using:

Modified check_decorators to work with multiple decorators for the same object
check_decorators <- function(x, names = NULL) { # nolint: object_name.

  check_message <- checkmate::check_list(x, names = "named")

  if (!is.null(names) && isTRUE(check_message)) {
    if (length(names(x)) != length(unique(names(x)))) {
      check_message <- sprintf(
        "The `decorators` must contain unique names from these names: %s",
        paste(names, collapse = ", ")
      )
    }
  } else {
    check_message <- sprintf(
      "The `decorators` must be a named list from these names: %s",
      paste(names, collapse = ", ")
    )
  }

  if (!isTRUE(check_message)) {
    return(check_message)
  }

  valid_elements <- vapply(
    x,
    checkmate::test_class,
    classes = "teal_transform_module",
    FUN.VALUE = logical(1L)
  )

  # Nested list
  if (any(!valid_elements)) {
    valid_nested <- vapply(
      x[!valid_elements], function(subdecorators) {
        checks <- vapply(subdecorators,
               checkmate::test_class,
               classes = "teal_transform_module",
               logical(1L))
        all(checks)
      },
      FUN.VALUE = logical(1L)
    )
    if (all(valid_nested)) {
      return(TRUE)
    }
  }

  if (all(valid_elements)) {
    return(TRUE)
  }

  "Make sure that the named list contains 'teal_transform_module' objects created using `teal_transform_module()`"
}
Example:
devtools::load_all()
data <- within(teal.data::teal_data(), {
    ADSL <- teal.data::rADSL
})
join_keys(data) <- default_cdisc_join_keys[names(data)]
app <- init(
    data = data,
    modules = modules(
        tm_gt_summary(
            by = teal.transform::data_extract_spec(
                dataname = "ADSL",
                select = teal.transform::select_spec(
                    choices = c("SEX", "COUNTRY", "SITEID", "ACTARM"),
                    selected = "SEX",
                    multiple = FALSE
                )
            ),
            include = teal.transform::data_extract_spec(
                dataname = "ADSL",
                select = teal.transform::select_spec(
                    choices = c("SITEID", "COUNTRY", "ACTARM"),
                    selected = "SITEID",
                    multiple = TRUE,
                    fixed = FALSE
                )
            )
        )
    )
)
if (interactive()) {
    shinyApp(app$ui, app$server)
}

@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented Jan 30, 2026

badge

Code Coverage Summary

Filename                      Stmts    Miss  Cover    Missing
--------------------------  -------  ------  -------  ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
R/geom_mosaic.R                  73       0  100.00%
R/tm_a_pca.R                    852       0  100.00%
R/tm_a_regression.R             751     391  47.94%   497-524, 531-534, 541-542, 546-556, 560, 564-576, 581-596, 601-623, 626-634, 638-666, 671-681, 684-710, 717-741, 744-769, 776-783, 786-812, 819-847, 850-875, 882-901, 904-930, 937-951, 954-980, 1005, 1009
R/tm_data_table.R               204      11  94.61%   110, 115-120, 225, 325, 330, 349
R/tm_file_viewer.R              172      50  70.93%   141-152, 156-163, 165-167, 172-182, 184, 204, 229-235, 237-238, 240, 244-250
R/tm_front_page.R               143       0  100.00%
R/tm_g_association.R            320      72  77.50%   226-294, 319, 325, 474, 488
R/tm_g_bivariate.R              672     196  70.83%   332-471, 505, 511-514, 585, 590, 595, 616-618, 655-658, 668-682, 684-685, 712-721, 763, 829, 940, 987, 989, 991, 998-1008
R/tm_g_distribution.R          1106      60  94.58%   407-415, 421-424, 463-464, 466, 468, 477, 479, 483-486, 490-493, 496, 508-509, 564, 575, 589, 884, 1068-1072, 1141-1145, 1147-1153, 1284-1287, 1305-1307, 1393-1394
R/tm_g_response.R               345      87  74.78%   184-185, 192, 198, 259-325, 348-352, 425, 430, 447-453, 531, 537, 550
R/tm_g_scatterplot.R            709     259  63.47%   359-501, 534-537, 575, 624, 636, 650, 667, 672, 698-711, 787-798, 837, 845-882, 894-896, 906-912, 919-921, 932-940, 942-943, 945, 949, 983-996, 1040, 1060
R/tm_g_scatterplotmatrix.R      272     110  59.56%   246-293, 357, 371-379, 396-437, 486-494, 562, 576
R/tm_gtsummary.R                182     182  0.00%    98-311
R/tm_missing_data.R            1172     167  85.75%   129, 480, 486, 499, 504, 524-530, 539-545, 612-627, 668, 672, 713, 731-738, 763, 779-782, 840-919, 923-925, 956-963, 1096, 1233, 1272, 1324-1326, 1336-1356, 1464, 1466, 1469-1470
R/tm_outliers.R                1029     186  81.92%   400, 428, 438-439, 441-442, 517-531, 533, 613, 616, 654-706, 709-747, 759, 790, 809, 812-827, 888-891, 965-993, 1117, 1220-1223, 1227, 1230-1233, 1239-1248, 1250, 1254-1263, 1266-1267, 1269
R/tm_rmarkdown.R                159       0  100.00%
R/tm_t_crosstable.R             263      56  78.71%   227-269, 292-293, 309, 318, 425, 438-447
R/tm_variable_browser.R         887      27  96.96%   395, 597, 812-826, 956, 984, 986, 1058-1059, 1067, 1165, 1246, 1278, 1310
R/utils.R                       185      26  85.95%   146, 177, 227, 292, 317-326, 331, 362, 388-391, 400-406, 414, 450, 453
R/zzz.R                           2       2  0.00%    2-3
TOTAL                          9498    1882  80.19%

Diff against main

Filename               Stmts    Miss  Cover
-------------------  -------  ------  --------
R/tm_gtsummary.R        +182    +182  +100.00%
R/tm_missing_data.R        0     +24  -2.05%
TOTAL                   +182    +206  -1.82%

Results for commit: 1e27b38

Minimum allowed coverage is 80%

♻️ This comment has been updated with latest results

@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented Jan 30, 2026

Unit Tests Summary

    1 files     38 suites   24m 23s ⏱️
  681 tests   673 ✅ 5 💤 0 ❌ 3 🔥
1 299 runs  1 291 ✅ 5 💤 0 ❌ 3 🔥

For more details on these errors, see this check.

Results for commit 6403bc2.

♻️ This comment has been updated with latest results.

@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented Jan 30, 2026

Unit Test Performance Difference

Test Suite $Status$ Time on main $±Time$ $±Tests$ $±Skipped$ $±Failures$ $±Errors$
shinytest2-tm_a_pca 💔 $219.10$ $+2.32$ $0$ $0$ $0$ $0$
shinytest2-tm_a_regression 💔 $83.68$ $+1.85$ $0$ $0$ $0$ $0$
shinytest2-tm_g_bivariate 💔 $99.36$ $+1.83$ $0$ $0$ $0$ $0$
shinytest2-tm_g_distribution 💔 $115.69$ $+1.17$ $0$ $0$ $0$ $0$
shinytest2-tm_g_response 💚 $45.62$ $-15.10$ $-7$ $0$ $0$ $+3$
shinytest2-tm_g_scatterplot 💔 $136.76$ $+1.97$ $0$ $0$ $0$ $0$
shinytest2-tm_missing_data 💔 $65.41$ $+2.25$ $0$ $0$ $0$ $0$
shinytest2-tm_outliers 💔 $167.96$ $+2.57$ $0$ $0$ $0$ $0$
shinytest2-tm_variable_browser 💔 $94.21$ $+1.54$ $0$ $0$ $0$ $0$
tm_gtsummary 👶 $+9.98$ $+26$ $0$ $0$ $0$
tm_outliers 💔 $50.98$ $+2.32$ $0$ $0$ $0$ $0$
variable_browser 💔 $67.61$ $+1.27$ $0$ $0$ $0$ $0$
Additional test case details
Test Suite $Status$ Time on main $±Time$ Test Case
examples 👶 $+0.07$ example_tm_gtsummary.Rd
shinytest2-tm_g_bivariate 💔 $38.09$ $+1.04$ e2e_tm_g_bivariate_Setting_encoding_inputs_produces_outputs_without_validation_errors.
shinytest2-tm_g_response 💚 $9.33$ $-5.62$ e2e_tm_g_response_deselecting_response_produces_validation_error.
shinytest2-tm_g_response 💚 $9.24$ $-5.35$ e2e_tm_g_response_deselecting_x_produces_validation_error.
shinytest2-tm_g_response 💔 $18.15$ $+1.04$ e2e_tm_g_response_encoding_inputs_produce_output_without_validation_errors.
shinytest2-tm_g_response 💚 $8.91$ $-5.17$ e2e_tm_g_response_module_is_initialised_with_the_specified_defaults.
tm_gtsummary 👶 $+0.00$ tm_gtsummary_input_validation_accepts_valid_decorators
tm_gtsummary 👶 $+0.01$ tm_gtsummary_input_validation_creates_a_teal_module_object_with_list_of_data_extract_specs
tm_gtsummary 👶 $+0.01$ tm_gtsummary_input_validation_fails_when_by_allows_multiple_selection
tm_gtsummary 👶 $+0.01$ tm_gtsummary_input_validation_fails_when_by_is_not_a_data_extract_spec_or_list
tm_gtsummary 👶 $+0.01$ tm_gtsummary_input_validation_fails_when_col_label_is_not_character
tm_gtsummary 👶 $+0.01$ tm_gtsummary_input_validation_fails_when_decorators_has_invalid_object_types
tm_gtsummary 👶 $+0.01$ tm_gtsummary_input_validation_fails_when_decorators_is_to_a_different_object
tm_gtsummary 👶 $+0.01$ tm_gtsummary_input_validation_fails_when_include_is_not_a_data_extract_spec_or_list
tm_gtsummary 👶 $+0.01$ tm_gtsummary_input_validation_fails_when_label_is_not_a_string
tm_gtsummary 👶 $+0.01$ tm_gtsummary_input_validation_pass_when_include_allows_multiple_selection
tm_gtsummary 👶 $+0.01$ tm_gtsummary_module_creation_accepts_crane_tbl_roche_summary_arguments
tm_gtsummary 👶 $+0.01$ tm_gtsummary_module_creation_creates_a_module_that_is_bookmarkable
tm_gtsummary 👶 $+0.01$ tm_gtsummary_module_creation_creates_a_module_with_datanames_taken_from_data_extracts
tm_gtsummary 👶 $+0.02$ tm_gtsummary_module_creation_creates_a_teal_module_object
tm_gtsummary 👶 $+0.02$ tm_gtsummary_module_creation_creates_a_teal_module_object_with_list_of_data_extract_specs
tm_gtsummary 👶 $+2.41$ tm_gtsummary_module_server_behavior_server_function_executes_successfully_through_module_interface
tm_gtsummary 👶 $+2.07$ tm_gtsummary_module_server_behavior_server_function_generates_table_with_col_label_
tm_gtsummary 👶 $+3.07$ tm_gtsummary_module_server_behavior_server_function_generates_table_with_include_being_NULL
tm_gtsummary 👶 $+2.24$ tm_gtsummary_module_server_behavior_with_decorators_one_decorator_executes_successfully
tm_gtsummary 👶 $+0.05$ tm_gtsummary_module_ui_behavior_returns_a_htmltools_tag_or_taglist_with_minimal_arguments
tm_outliers 💔 $2.28$ $+1.03$ tm_outliers_edge_cases_server_tests_server_handles_Cumulative_Distribution_Plot_with_categorical_variables

Results for commit 7159e43

♻️ This comment has been updated with latest results.

@osenan osenan self-assigned this Jan 30, 2026
Copy link
Copy Markdown
Contributor

@osenan osenan left a comment

Choose a reason for hiding this comment

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

Hi, very good job!

I have provided small comments on function arguments and assertions. I have reviewed the full ui part and all parts of the server that I was confident. The last elements when we "print" the content of the qenv to subsequent qenv looks slightly unknown to me.

I cannot see the table in the example you provided, is it working for you?

Image

On new iteration I will check the documentation.

Comment thread R/gtsummary.R Outdated
Comment thread R/tm_gtsummary.R
Comment thread R/gtsummary.R Outdated
Comment thread R/tm_gtsummary.R
Comment thread R/tm_gtsummary.R Outdated
Comment thread R/tm_gtsummary.R
Comment thread R/tm_gtsummary.R
Comment thread DESCRIPTION
@llrs-roche
Copy link
Copy Markdown
Contributor Author

llrs-roche commented Feb 2, 2026

Yes the example works as is for me with the devel version of all packagesimage

But I see you want to explore a table which summarizes the same column as you split the table by (so to speak the x and y on the table are the same, so the values are empty). Maybe I should check those arguments and provide an informative error on this case, but I thought that teal would provide the tbl_summary() error message (but I think that could improve too):

library("gtsummary")
library("dplyr")
#> 
#> Attaching package: 'dplyr'
#> The following objects are masked from 'package:stats':
#> 
#>     filter, lag
#> The following objects are masked from 'package:base':
#> 
#>     intersect, setdiff, setequal, union
trial |>
  select(age, grade, response) |>
  tbl_summary(by = "age", include = "age")
#> 11 missing rows in the "age" column have been removed.
#> Error in `rep_named()`:
#> ! `names` must be `NULL` or a character vector, not an empty integer
#>   vector.

Created on 2026-02-02 with reprex v2.1.1


Edit: I already put some safeguards to prevent that to happen.
"Variables to stratify with and variables to include should be different" How did you get to have the app stable without that warning?

Copy link
Copy Markdown
Contributor

@osenan osenan left a comment

Choose a reason for hiding this comment

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

Thanks for the changes. I have now tested the app. All the validate::need calls are appearing. Let's find more bugs with more dry runs or with the unit tests let's check the edge cases.

I found this curiosity of calling a hash function on the report:

ADSL <- teal.data::rADSL
stopifnot(rlang::hash(ADSL) == "843e317c3d4aeb88062cd39a9c62fe8a") # @linksto ADSL
.raw_data <- list2env(list(ADSL = ADSL))
lockEnvironment(.raw_data) # @linksto .raw_data
library(crane)
table <- crane::tbl_roche_summary(data = ADSL, by = "SITEID", include = c("SITEID", "ACTARM"), nonmissing = "ifany", percent = "column")
table

Just decide if you want to move the include before the ... or you prefer to keep it where it is. In that case, let's see if it can be set to NULL

Comment thread R/gtsummary.R Outdated
@llrs-roche llrs-roche mentioned this pull request Feb 3, 2026
6 tasks
@llrs-roche
Copy link
Copy Markdown
Contributor Author

Waiting to merge to see if we can merge a feature complete module with the work on:

Comment thread inst/WORDLIST
Copy link
Copy Markdown
Contributor

@m7pr m7pr left a comment

Choose a reason for hiding this comment

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

For now reviewed documentation and DESCRIPTION

Comment thread R/tm_gtsummary.R Outdated
Comment thread R/tm_gtsummary.R Outdated
Comment thread R/tm_gtsummary.R Outdated
Comment thread R/tm_gtsummary.R Outdated
Comment thread R/tm_gtsummary.R Outdated
Comment thread R/tm_gtsummary.R Outdated
Comment thread DESCRIPTION Outdated
Comment thread R/tm_gtsummary.R Outdated
Comment thread DESCRIPTION Outdated
Co-authored-by: Marcin <133694481+m7pr@users.noreply.github.com>
Signed-off-by: Lluís Revilla <185338939+llrs-roche@users.noreply.github.com>
Comment thread R/tm_gtsummary.R
Comment thread R/tm_gtsummary.R Outdated
@m7pr
Copy link
Copy Markdown
Contributor

m7pr commented Feb 4, 2026

So far I was able to run the example from the opening of this PR on a tests branch.
And I was able to create a table, add it to the report and display in a report

image

Will now try to test with a decorator

@m7pr
Copy link
Copy Markdown
Contributor

m7pr commented Feb 4, 2026

Also works with a decorator

Code
# Example: tm_gtsummary with decorators
# This example demonstrates how to use decorators with the tm_gtsummary module.

library(teal.modules.general)

# Setup data
data <- within(teal.data::teal_data(), {
  ADSL <- teal.data::rADSL
})
join_keys(data) <- default_cdisc_join_keys[names(data)]

# ============================================================================
# Decorator 1: Modifies the gtsummary table header (keeps as gtsummary)
# ============================================================================
# This decorator modifies the table caption using gtsummary functions,
# keeping the table as a gtsummary object (no conversion to gt needed).

table_caption_decorator <- function(default_caption = "Summary Table") {
  teal::teal_transform_module(
    label = "Table Caption",
    ui = function(id) {
      ns <- shiny::NS(id)
      shiny::tagList(
        shiny::textInput(
          ns("caption"),
          "Table Caption",
          value = default_caption
        )
      )
    },
    server = function(id, data) {
      shiny::moduleServer(id, function(input, output, session) {
        shiny::reactive({
          req(data())
          within(
            data(),
            {
              # Keep the table as gtsummary object
              # Modify using gtsummary functions - no conversion to gt needed
              if (inherits(table, "gtsummary")) {
                # Modify the table caption using gtsummary::modify_caption()
                if (nchar(caption) > 0) {
                  table <- table |>
                    gtsummary::modify_caption(caption)
                }
              }
              # If it's already gt, leave it as is (shouldn't happen with this decorator)
            },
            caption = input$caption
          )
        })
      })
    }
  )
}

# ============================================================================
# Decorator 2: Modifies the gtsummary table output (converts to gt for footnotes)
# ============================================================================
# A decorator modifies the module's output after it's generated.
# This example creates a decorator that adds a footnote and modifies table formatting.
# Note: This one converts to gt because gtsummary doesn't have direct footnote functions.

table_footnote_decorator <- function(default_footnote = "Summary statistics") {
  teal::teal_transform_module(
    label = "Table Footnote",
    ui = function(id) {
      ns <- shiny::NS(id)
      shiny::tagList(
        shiny::textInput(
          ns("footnote"),
          "Table Footnote",
          value = default_footnote
        )
      )
    },
    server = function(id, data) {
      shiny::moduleServer(id, function(input, output, session) {
        shiny::reactive({
          req(data())
          within(
            data(),
            {
              # The table is a gtsummary object (from crane::tbl_roche_summary)
              # We need to convert to gt to add footnotes because gtsummary doesn't have
              # a direct tab_footnote() function. The module will handle the gt object
              # correctly when displaying.
              
              # Only process if it's still a gtsummary object (not already converted)
              if (inherits(table, "gtsummary")) {
                # Convert to gt to add footnote
                table_gt <- gtsummary::as_gt(table)
                
                if (nchar(footnote) > 0) {
                  table_gt <- table_gt |>
                    gt::tab_footnote(
                      footnote = footnote,
                      locations = gt::cells_title()
                    )
                }
                
                # Store as gt - the module's table_r reactive handles both gtsummary and gt
                table <- table_gt
              } else if (inherits(table, "gt_tbl")) {
                # Already converted to gt - don't modify again to avoid duplicate footnotes
                table <- table
              }
            },
            footnote = input$footnote
          )
        })
      })
    }
  )
}

# ============================================================================
# Create the app with decorator
# ============================================================================
app <- init(
  data = data,
  modules = modules(
    tm_gtsummary(
      by = teal.transform::data_extract_spec(
        dataname = "ADSL",
        select = teal.transform::select_spec(
          choices = c("SEX", "COUNTRY", "SITEID", "ACTARM"),
          selected = "SEX",
          multiple = FALSE
        )
      ),
      include = teal.transform::data_extract_spec(
        dataname = "ADSL",
        select = teal.transform::select_spec(
          choices = c("SITEID", "COUNTRY", "ACTARM"),
          selected = "SITEID",
          multiple = TRUE,
          fixed = FALSE
        )
      ),
      # Decorator: Applied to the table output
      # This example uses the caption decorator which keeps the table as gtsummary
      decorators = list(
        table = table_caption_decorator("Demographic Summary Table CAPTION EXTRA")
      )
    )
  )
)

if (interactive()) {
  shinyApp(app$ui, app$server)
}

image

llrs-roche and others added 6 commits February 5, 2026 11:50
# Pull Request

Adds tests for #973


TODO:
- [x] Test input
- [x] Test UI
- [x] Test output

On a different PR:
- [ ] Test decorators:
  - [ ] One
  - [ ] Multiple

---------

Signed-off-by: Lluís Revilla <185338939+llrs-roche@users.noreply.github.com>
Co-authored-by: Marcin <133694481+m7pr@users.noreply.github.com>
@m7pr
Copy link
Copy Markdown
Contributor

m7pr commented Feb 5, 2026

Hey I can confirm I reviewed docs, imports and implementation.
I also run an example with a decorator and it still works (code #973 (comment))

image

TODO

Copy link
Copy Markdown
Contributor

@m7pr m7pr left a comment

Choose a reason for hiding this comment

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

solid

@llrs-roche llrs-roche merged commit df089ef into main Feb 9, 2026
27 of 29 checks passed
@llrs-roche llrs-roche deleted the tm_gtsummary branch February 9, 2026 08:55
@github-actions github-actions Bot locked and limited conversation to collaborators Feb 9, 2026
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants