diff --git a/NAMESPACE b/NAMESPACE index 9edefe8d..e7fa3144 100644 --- a/NAMESPACE +++ b/NAMESPACE @@ -46,6 +46,7 @@ export(label_roche_percent) export(label_roche_pvalue) export(label_roche_ratio) export(modify_header_rm_md) +export(modify_split_caption) export(modify_zero_recode) export(preprocess_lineplot_data) export(process_survfit) diff --git a/NEWS.md b/NEWS.md index 9d1f4f65..3952ad62 100644 --- a/NEWS.md +++ b/NEWS.md @@ -1,5 +1,7 @@ # crane 0.3.3.9009 +* Added `modify_split_caption()` to subtitle each page of a split `{gtsummary}` table (e.g. from `tbl_listing()`, `tbl_baseline_chg()`, or `tbl_shift()`) from its split level via a glue `pattern` (default `"Parameter: {spl_level}"`) and hide the now-redundant split column. (#282) + * `annotate_riskdf()` now builds the "Numbers at Risk" table at the plot's x-axis breaks, so custom ticks set with `ggplot2::scale_x_continuous(breaks = ...)` are reflected in the table. (#278) * `theme_gtsummary_roche()` now frames the flextable column labels with an outer border only, removing the internal borders between header rows and the inconsistent missing right border. (#272) diff --git a/R/modify_split_caption.R b/R/modify_split_caption.R new file mode 100644 index 00000000..3a1b56fc --- /dev/null +++ b/R/modify_split_caption.R @@ -0,0 +1,108 @@ +#' Label and Trim Split Tables +#' +#' @description +#' Post-process a table that was paginated with +#' [gtsummary::tbl_split_by_rows()] (via its `variable_level` argument). For each +#' page, a subtitle is built from the split level using a [glue()] +#' `pattern`, and the now-redundant split column is hidden. +#' +#' This is useful for any split `{gtsummary}` table, such as listings +#' ([tbl_listing()]) split by treatment, or baseline/change tables +#' ([tbl_baseline_chg()]) and shift tables ([tbl_shift()]) split by `PARAM`. +#' +#' The label is set both as a `{gtsummary}` caption (via +#' [gtsummary::modify_caption()]) and on the native `variable_level` attribute. +#' The caption makes the subtitle visible when the table is printed on its own +#' (it renders inside the table's `` element), while `variable_level` +#' lets downstream reporting engines promote the same text to a dedicated +#' subtitle row. +#' +#' @param x (`tbl_split`, `list`, or `gtsummary`)\cr +#' a table split with [gtsummary::tbl_split_by_rows()], the list of split +#' tables, or a single split page. +#' @param spl_col (`string`)\cr +#' name of the column the table was split by (the `variable_level` passed to +#' [gtsummary::tbl_split_by_rows()]). Used to hide that column on each page +#' when `hide_spl_col = TRUE`. +#' @param pattern (`string`)\cr +#' a [glue()] pattern used to build each page's subtitle from the split level. +#' The `{spl_level}` token is replaced by the split value. Defaults to +#' `"Parameter: {spl_level}"`, matching parameter-split tables such as +#' laboratory tables split by `PARAM`. +#' @param hide_spl_col (`flag`)\cr +#' whether to hide `spl_col` on each split page, since it is redundant after a +#' single-level split. Defaults to `TRUE`. Silently does nothing when the +#' column is absent or already hidden. +#' +#' @returns an object of the same class as `x`, with the split subtitle set as a +#' caption and on the `variable_level` attribute (and the split column hidden) +#' on each page. +#' +#' @seealso [tbl_listing()], [tbl_baseline_chg()], [tbl_shift()], +#' [gtsummary::tbl_split_by_rows()] +#' +#' @examples +#' lst <- gtsummary::trial |> +#' dplyr::select(trt, age, grade) |> +#' dplyr::arrange(trt) |> +#' tbl_listing(split_by_rows = list(variable_level = "trt")) +#' +#' # Split a listing by treatment, then subtitle each page and drop the column +#' modify_split_caption(lst, spl_col = "trt", pattern = "Treatment: {spl_level}")[[1]] +#' +#' # Keep the split column and use the default "Parameter:" subtitle +#' modify_split_caption(lst, spl_col = "trt", hide_spl_col = FALSE)[[1]] +#' +#' @export +modify_split_caption <- function(x, + spl_col, + pattern = "Parameter: {spl_level}", + hide_spl_col = TRUE) { + set_cli_abort_call() + + # checks --------------------------------------------------------------------- + # argument checks run before the map so a list of split tables is validated + # once, up front, rather than on each recursive call. + check_not_missing(x) + check_not_missing(spl_col) + check_string(spl_col) + check_string(pattern) + check_scalar_logical(hide_spl_col) + + # map over a list of split tables -------------------------------------------- + if (is.list(x) && inherits(x[[1]], "gtsummary")) { + return( + map( + x, + modify_split_caption, + spl_col = spl_col, + pattern = pattern, + hide_spl_col = hide_spl_col + ) |> + structure(class = class(x)) + ) + } + + check_class(x, "gtsummary") + + # build the split subtitle from the split level ------------------------------ + # `variable_level` is the gtsummary-native attribute set by + # tbl_split_by_rows(variable_level = ); row-number splits do not set it, so + # those pages are skipped silently. + spl_level <- attr(x, "variable_level") + if (!is_empty(spl_level)) { + subtitle <- as.character(glue::glue(pattern)) + # set the label as a caption so it is visible when the page is printed on + # its own, and mirror it onto `variable_level` so reporting engines can + # promote the same text to a dedicated subtitle row. + x <- gtsummary::modify_caption(x, caption = subtitle) + attr(x, "variable_level") <- subtitle + } + + # hide the split column, if present and not already hidden -------------------- + if (isTRUE(hide_spl_col) && all(spl_col %in% x$table_styling$header$column)) { + x <- gtsummary::modify_column_hide(x, columns = all_of(spl_col)) + } + + x +} diff --git a/R/tbl_baseline_chg.R b/R/tbl_baseline_chg.R index 6cabd029..7747adab 100644 --- a/R/tbl_baseline_chg.R +++ b/R/tbl_baseline_chg.R @@ -67,8 +67,12 @@ #' .combine_with = "tbl_stack", #' .combine_args = list(group_header = NULL, quiet = TRUE) #' ) |> -#' tbl_split_by_rows(variable_level = ends_with("lbl")) +#' tbl_split_by_rows(variable_level = ends_with("lbl")) |> +#' # caption each page with its parameter and hide the split column +#' modify_split_caption(spl_col = "tbl_id1_lbl") #' +#' @seealso [modify_split_caption()] to caption each page of a split table and +#' hide the redundant split column. #' @rdname tbl_baseline_chg #' @export tbl_baseline_chg <- function(data, diff --git a/R/tbl_listing.R b/R/tbl_listing.R index 2bbad49b..dbeb29ff 100644 --- a/R/tbl_listing.R +++ b/R/tbl_listing.R @@ -22,6 +22,8 @@ #' string to use for blank values. Defaults to `NA`. It should not be changed. #' #' @name tbl_listing +#' @seealso [modify_split_caption()] to label each page of a split table and +#' hide the redundant split column. #' @note #' Common pre-processing steps for the data frame that may be common: #' * Unique values - this should be enforced in pre-processing by users. @@ -88,6 +90,12 @@ #' out <- list_lst |> #' remove_duplicate_keys(keys = c("trt", "stage")) #' out[[2]] +#' +#' # Example 8 -------------------------------- +#' # Label each split page and hide the redundant split column +#' by_stage <- tbl_listing(trial_data, split_by_rows = list(variable_level = "stage")) |> +#' modify_split_caption(spl_col = "stage", pattern = "Stage: {spl_level}") +#' by_stage[[1]] NULL #' @export diff --git a/R/tbl_shift.R b/R/tbl_shift.R index d2694f6c..6b3a917c 100644 --- a/R/tbl_shift.R +++ b/R/tbl_shift.R @@ -140,6 +140,8 @@ #' modify_spanning_header(all_stat_cols() ~ "Worst Post-baseline NCI-CTCAE Grade") NULL +#' @seealso [modify_split_caption()] to caption each page of a split table and +#' hide the redundant split column. #' @rdname tbl_shift #' @export tbl_shift <- function(data, diff --git a/_pkgdown.yml b/_pkgdown.yml index cd0deb8e..78ec8d56 100644 --- a/_pkgdown.yml +++ b/_pkgdown.yml @@ -90,6 +90,7 @@ reference: - theme_gtsummary_roche - modify_header_rm_md - modify_zero_recode + - modify_split_caption - add_blank_rows - label_roche - reverse_ci diff --git a/man/modify_split_caption.Rd b/man/modify_split_caption.Rd new file mode 100644 index 00000000..026bbcea --- /dev/null +++ b/man/modify_split_caption.Rd @@ -0,0 +1,73 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/modify_split_caption.R +\name{modify_split_caption} +\alias{modify_split_caption} +\title{Label and Trim Split Tables} +\usage{ +modify_split_caption( + x, + spl_col, + pattern = "Parameter: {spl_level}", + hide_spl_col = TRUE +) +} +\arguments{ +\item{x}{(\code{tbl_split}, \code{list}, or \code{gtsummary})\cr +a table split with \code{\link[gtsummary:tbl_split_by_rows]{gtsummary::tbl_split_by_rows()}}, the list of split +tables, or a single split page.} + +\item{spl_col}{(\code{string})\cr +name of the column the table was split by (the \code{variable_level} passed to +\code{\link[gtsummary:tbl_split_by_rows]{gtsummary::tbl_split_by_rows()}}). Used to hide that column on each page +when \code{hide_spl_col = TRUE}.} + +\item{pattern}{(\code{string})\cr +a \code{\link[glue:glue]{glue()}} pattern used to build each page's subtitle from the split level. +The \code{{spl_level}} token is replaced by the split value. Defaults to +\code{"Parameter: {spl_level}"}, matching parameter-split tables such as +laboratory tables split by \code{PARAM}.} + +\item{hide_spl_col}{(\code{flag})\cr +whether to hide \code{spl_col} on each split page, since it is redundant after a +single-level split. Defaults to \code{TRUE}. Silently does nothing when the +column is absent or already hidden.} +} +\value{ +an object of the same class as \code{x}, with the split subtitle set as a +caption and on the \code{variable_level} attribute (and the split column hidden) +on each page. +} +\description{ +Post-process a table that was paginated with +\code{\link[gtsummary:tbl_split_by_rows]{gtsummary::tbl_split_by_rows()}} (via its \code{variable_level} argument). For each +page, a subtitle is built from the split level using a \code{\link[glue:glue]{glue()}} +\code{pattern}, and the now-redundant split column is hidden. + +This is useful for any split \code{{gtsummary}} table, such as listings +(\code{\link[=tbl_listing]{tbl_listing()}}) split by treatment, or baseline/change tables +(\code{\link[=tbl_baseline_chg]{tbl_baseline_chg()}}) and shift tables (\code{\link[=tbl_shift]{tbl_shift()}}) split by \code{PARAM}. + +The label is set both as a \code{{gtsummary}} caption (via +\code{\link[gtsummary:modify_caption]{gtsummary::modify_caption()}}) and on the native \code{variable_level} attribute. +The caption makes the subtitle visible when the table is printed on its own +(it renders inside the table's \verb{} element), while \code{variable_level} +lets downstream reporting engines promote the same text to a dedicated +subtitle row. +} +\examples{ +lst <- gtsummary::trial |> + dplyr::select(trt, age, grade) |> + dplyr::arrange(trt) |> + tbl_listing(split_by_rows = list(variable_level = "trt")) + +# Split a listing by treatment, then subtitle each page and drop the column +modify_split_caption(lst, spl_col = "trt", pattern = "Treatment: {spl_level}")[[1]] + +# Keep the split column and use the default "Parameter:" subtitle +modify_split_caption(lst, spl_col = "trt", hide_spl_col = FALSE)[[1]] + +} +\seealso{ +\code{\link[=tbl_listing]{tbl_listing()}}, \code{\link[=tbl_baseline_chg]{tbl_baseline_chg()}}, \code{\link[=tbl_shift]{tbl_shift()}}, +\code{\link[gtsummary:tbl_split_by_rows]{gtsummary::tbl_split_by_rows()}} +} diff --git a/man/tbl_baseline_chg.Rd b/man/tbl_baseline_chg.Rd index 5b14bd4c..2a21cf4d 100644 --- a/man/tbl_baseline_chg.Rd +++ b/man/tbl_baseline_chg.Rd @@ -126,6 +126,12 @@ tbl_strata( .combine_with = "tbl_stack", .combine_args = list(group_header = NULL, quiet = TRUE) ) |> - tbl_split_by_rows(variable_level = ends_with("lbl")) + tbl_split_by_rows(variable_level = ends_with("lbl")) |> + # caption each page with its parameter and hide the split column + modify_split_caption(spl_col = "tbl_id1_lbl") \dontshow{\}) # examplesIf} } +\seealso{ +\code{\link[=modify_split_caption]{modify_split_caption()}} to caption each page of a split table and +hide the redundant split column. +} diff --git a/man/tbl_listing.Rd b/man/tbl_listing.Rd index 8ab2b6d3..3fbde611 100644 --- a/man/tbl_listing.Rd +++ b/man/tbl_listing.Rd @@ -116,5 +116,15 @@ list_lst[[2]] out <- list_lst |> remove_duplicate_keys(keys = c("trt", "stage")) out[[2]] + +# Example 8 -------------------------------- +# Label each split page and hide the redundant split column +by_stage <- tbl_listing(trial_data, split_by_rows = list(variable_level = "stage")) |> + modify_split_caption(spl_col = "stage", pattern = "Stage: {spl_level}") +by_stage[[1]] \dontshow{\}) # examplesIf} } +\seealso{ +\code{\link[=modify_split_caption]{modify_split_caption()}} to label each page of a split table and +hide the redundant split column. +} diff --git a/man/tbl_shift.Rd b/man/tbl_shift.Rd index 3ae82e6a..23a0458f 100644 --- a/man/tbl_shift.Rd +++ b/man/tbl_shift.Rd @@ -183,3 +183,7 @@ filter(adlb, PARAMCD \%in\% "CHOLES") |> modify_spanning_header(all_stat_cols() ~ "Worst Post-baseline NCI-CTCAE Grade") \dontshow{\}) # examplesIf} } +\seealso{ +\code{\link[=modify_split_caption]{modify_split_caption()}} to caption each page of a split table and +hide the redundant split column. +} diff --git a/tests/testthat/test-modify_split_caption.R b/tests/testthat/test-modify_split_caption.R new file mode 100644 index 00000000..be5953ad --- /dev/null +++ b/tests/testthat/test-modify_split_caption.R @@ -0,0 +1,100 @@ +tld <- trial |> # table_listing_data + dplyr::select(trt, age, marker, stage) |> + dplyr::filter(stage %in% c("T2", "T3")) |> # down sampling + dplyr::slice_head(n = 2, by = c(trt, stage)) + +# get the hide flag of a single column from a gtsummary table +hide_flag <- function(x, col) { + x$table_styling$header$hide[x$table_styling$header$column == col] +} + +test_that("modify_split_caption() labels each page and hides the split column", { + expect_no_error( + out <- tbl_listing(tld, split_by_rows = list(variable_level = "trt")) |> + modify_split_caption(spl_col = "trt") + ) + + # one page per treatment level, class preserved + expect_s3_class(out, "tbl_split") + expect_s3_class(out[[1]], "tbl_listing") + + # default pattern is applied on every page as both a caption (visible when + # the page prints on its own) and the variable_level attribute (promoted to a + # subtitle row by reporting engines) + expect_equal(attr(out[[1]], "variable_level"), "Parameter: Drug B") + expect_equal(attr(out[[2]], "variable_level"), "Parameter: Drug A") + expect_equal(as.character(out[[1]]$table_styling$caption), "Parameter: Drug B") + expect_equal(as.character(out[[2]]$table_styling$caption), "Parameter: Drug A") + + # the split column is hidden by default on every page + expect_true(hide_flag(out[[1]], "trt")) + expect_true(hide_flag(out[[2]], "trt")) +}) + +test_that("modify_split_caption() honors a custom glue pattern", { + out <- tbl_listing(tld, split_by_rows = list(variable_level = "trt")) |> + modify_split_caption(spl_col = "trt", pattern = "Treatment: {spl_level}") + + expect_equal(attr(out[[1]], "variable_level"), "Treatment: Drug B") +}) + +test_that("modify_split_caption(hide_spl_col = FALSE) keeps the split column", { + out <- tbl_listing(tld, split_by_rows = list(variable_level = "trt")) |> + modify_split_caption(spl_col = "trt", hide_spl_col = FALSE) + + expect_false(hide_flag(out[[1]], "trt")) + # subtitle is still applied + expect_equal(attr(out[[1]], "variable_level"), "Parameter: Drug B") +}) + +test_that("modify_split_caption() is silent when the column is absent or already hidden", { + base <- tbl_listing(tld, split_by_rows = list(variable_level = "trt")) + + # absent column -> no error, subtitle still applied from the split level + expect_no_error( + out_absent <- modify_split_caption(base, spl_col = "not_a_column") + ) + expect_equal(attr(out_absent[[1]], "variable_level"), "Parameter: Drug B") + + # applying twice is idempotent (column already hidden the second time) + expect_no_error( + out_twice <- base |> + modify_split_caption(spl_col = "trt") |> + modify_split_caption(spl_col = "trt") + ) + expect_true(hide_flag(out_twice[[1]], "trt")) +}) + +test_that("modify_split_caption() skips row-number splits that lack a level", { + # row_numbers splits do not set the variable_level attribute + out <- tbl_listing(tld, split_by_rows = list(row_numbers = c(2, 3))) |> + modify_split_caption(spl_col = "trt", hide_spl_col = FALSE) + + expect_null(attr(out[[1]], "variable_level")) +}) + +test_that("modify_split_caption() works on a single (non-list) split element", { + one <- tbl_listing(tld, split_by_rows = list(variable_level = "trt"))[[1]] + out <- modify_split_caption(one, spl_col = "trt") + + expect_s3_class(out, "tbl_listing") + expect_equal(attr(out, "variable_level"), "Parameter: Drug B") + expect_true(hide_flag(out, "trt")) +}) + +test_that("modify_split_caption() checks its inputs", { + one <- tbl_listing(tld, split_by_rows = list(variable_level = "trt"))[[1]] + + expect_error( + modify_split_caption(data.frame(a = 1), spl_col = "trt"), + "must be class" + ) + expect_error( + modify_split_caption(one), + "cannot be missing" + ) + expect_error( + modify_split_caption(one, spl_col = "trt", pattern = 5), + "must be a string" + ) +})