diff --git a/.github/workflows/R-CMD-check.yaml b/.github/workflows/R-CMD-check.yaml new file mode 100644 index 0000000..5a774a2 --- /dev/null +++ b/.github/workflows/R-CMD-check.yaml @@ -0,0 +1,51 @@ +# Workflow derived from https://github.com/r-lib/actions/tree/v2/examples +# Need help debugging build failures? Start at https://github.com/r-lib/actions#where-to-find-help +on: + push: + branches: [main, master] + pull_request: + +name: R-CMD-check.yaml + +permissions: read-all + +jobs: + R-CMD-check: + runs-on: ${{ matrix.config.os }} + + name: ${{ matrix.config.os }} (${{ matrix.config.r }}) + + strategy: + fail-fast: false + matrix: + config: + - {os: macos-latest, r: 'release'} + - {os: windows-latest, r: 'release'} + - {os: ubuntu-latest, r: 'devel', http-user-agent: 'release'} + - {os: ubuntu-latest, r: 'release'} + - {os: ubuntu-latest, r: 'oldrel-1'} + + env: + GITHUB_PAT: ${{ secrets.GITHUB_TOKEN }} + R_KEEP_PKG_SOURCE: yes + + steps: + - uses: actions/checkout@v4 + + - uses: r-lib/actions/setup-pandoc@v2 + + - uses: r-lib/actions/setup-r@v2 + with: + r-version: ${{ matrix.config.r }} + http-user-agent: ${{ matrix.config.http-user-agent }} + use-public-rspm: true + + - uses: r-lib/actions/setup-r-dependencies@v2 + with: + extra-packages: any::rcmdcheck + needs: check + + - uses: r-lib/actions/check-r-package@v2 + with: + upload-snapshots: true + build_args: 'c("--no-manual","--compact-vignettes=gs+qpdf")' diff --git a/DESCRIPTION b/DESCRIPTION index 18f0a99..f774179 100644 --- a/DESCRIPTION +++ b/DESCRIPTION @@ -35,5 +35,5 @@ Suggests: testthat (>= 3.1.0) VignetteBuilder: knitr Roxygen: list(markdown = TRUE) -RoxygenNote: 7.3.3 Config/testthat/edition: 3 +Config/roxygen2/version: 8.0.0 diff --git a/R/dt2.R b/R/dt2.R index 0ee21fc..99e92f0 100644 --- a/R/dt2.R +++ b/R/dt2.R @@ -111,6 +111,7 @@ print.dt2_theme <- function(x, ...) { cat(" font_scale =", x$font_scale, "\n") cat(" style =", x$style, "\n") cat(" button_class =", x$button_class, "\n") + cat(" class =", x$class %||% "", "\n") invisible(x) } @@ -283,6 +284,16 @@ dt2 <- function(data, options$responsive <- NULL # ensure extension is not loaded } + # ---- Column names ----------------------------------------------------------- + # Expose the data's column names as options$columns when the user didn't set + # them. This is the canonical list that name-based helpers resolve against and + # is equivalent to the column list dt2.js derives client-side, so it does not + # change rendering -- it just makes the names available downstream. + if (is.null(options$columns)) { + cn <- colnames(data) + if (!is.null(cn)) options$columns <- cn + } + # ---- Extensions auto-detect ------------------------------------------------ if (is.null(extensions)) { extensions <- .dt2_detect_extensions(options) diff --git a/R/dt2_buttons.R b/R/dt2_buttons.R index a348803..75f671b 100644 --- a/R/dt2_buttons.R +++ b/R/dt2_buttons.R @@ -7,6 +7,12 @@ #' buttons container. If provided, DT2 will move the rendered buttons to that container after init. #' @return The modified `options` list. #' @details Requires the **Buttons** extension. For CSV/Excel/PDF you also need **JSZip** and **pdfMake** (incl. `vfs_fonts`). +#' +#' Prefer [dt2_use_buttons()] for the common case: it takes simple button ids, +#' styles them with a CSS class, and places them in the layout. Use +#' `dt2_buttons()` when you need full button objects or want to move the +#' rendered buttons into a custom container via `target`. +#' @seealso [dt2_use_buttons()] #' @export dt2_buttons <- function(options = list(), buttons = c("copyHtml5", "csvHtml5", "excelHtml5", "pdfHtml5", "print"), diff --git a/R/dt2_formats.R b/R/dt2_formats.R index 92f2f16..856b94c 100644 --- a/R/dt2_formats.R +++ b/R/dt2_formats.R @@ -19,17 +19,14 @@ dt2_format_number <- function(options = list(), col_specs, thousands = NULL, decimal = NULL, digits = 0, prefix = "", prefix_right = "") { - #`%||%` <- function(a, b) if (is.null(a)) b else a - if (is.character(col_specs)) col_specs <- match(col_specs, options$columns) + col_specs <- .dt2_name_to_idx(col_specs, options) # DataTables v2 sugere DataTable.render.number(...) # https://datatables.net/manual/data/renderers js <- htmlwidgets::JS( sprintf("DataTable.render.number(%s,%s,%d,%s,%s)", - if (is.null(thousands)) "null" else sprintf("'%s'", thousands), - if (is.null(decimal)) "null" else sprintf("'%s'", decimal), + .dt2_js_str(thousands), .dt2_js_str(decimal), as.integer(digits), - sprintf("'%s'", prefix), - sprintf("'%s'", prefix_right)) + .dt2_js_str(prefix), .dt2_js_str(prefix_right)) ) cds <- lapply(col_specs, function(i) list(targets = i - 1L, render = js)) options$columnDefs <- c(options$columnDefs %||% list(), cds) @@ -57,14 +54,13 @@ dt2_format_number <- function(options = list(), col_specs, dt2_format_datetime <- function(options = list(), col_specs, from = NULL, to = "DD/MM/YYYY", locale = NULL, def = NULL) { - #`%||%` <- function(a, b) if (is.null(a)) b else a - if (is.character(col_specs)) col_specs <- match(col_specs, options$columns) + col_specs <- .dt2_name_to_idx(col_specs, options) args <- c( - if (is.null(from)) "undefined" else sprintf("'%s'", from), - if (is.null(to)) "undefined" else sprintf("'%s'", to), - if (is.null(locale)) "undefined" else sprintf("'%s'", locale), - if (is.null(def)) "undefined" else sprintf("'%s'", def) + .dt2_js_str(from, "undefined"), + .dt2_js_str(to, "undefined"), + .dt2_js_str(locale, "undefined"), + .dt2_js_str(def, "undefined") ) # ver docs: https://datatables.net/plug-ins/dataRender/datetime + manual de renderers js <- htmlwidgets::JS(sprintf("DataTable.render.datetime(%s)", paste(args, collapse = ", "))) @@ -88,8 +84,7 @@ dt2_format_datetime <- function(options = list(), col_specs, #' @seealso \url{https://datatables.net/reference/option/columns.render} #' @export dt2_cols_render_js <- function(options = list(), col_specs, js_render) { - #`%||%` <- function(a, b) if (is.null(a)) b else a - if (is.character(col_specs)) col_specs <- match(col_specs, options$columns) + col_specs <- .dt2_name_to_idx(col_specs, options) stopifnot(inherits(js_render, "JS_EVAL")) cds <- lapply(col_specs, function(i) list(targets = i - 1L, render = js_render)) options$columnDefs <- c(options$columnDefs %||% list(), cds) @@ -121,8 +116,7 @@ dt2_cols_render_js <- function(options = list(), col_specs, js_render) { dt2_cols_render_orthogonal <- function(options = list(), col_specs, display = NULL, sort = NULL, filter = NULL, type = NULL) { - #`%||%` <- function(a, b) if (is.null(a)) b else a - if (is.character(col_specs)) col_specs <- match(col_specs, options$columns) + col_specs <- .dt2_name_to_idx(col_specs, options) # Build an {display, sort, filter, type} object with the supplied parts @@ -159,8 +153,7 @@ dt2_cols_render_orthogonal <- function(options = list(), col_specs, #' opts <- list(columns = names(mtcars)) #' opts <- dt2_format_number_abbrev(opts, c("hp","qsec"), digits = 1, locale = "pt-BR") dt2_format_number_abbrev <- function(options = list(), col_specs, digits = 1, locale = NULL) { - `%||%` <- function(a, b) if (is.null(a)) b else a - if (is.character(col_specs)) col_specs <- match(col_specs, options$columns) + col_specs <- .dt2_name_to_idx(col_specs, options) # JS renderer: abrevia com k/M/B e aplica toLocaleString(locale) na parte inteira se locale fornecido js <- if (is.null(locale) || !nzchar(locale)) { @@ -206,17 +199,17 @@ dt2_format_number_abbrev <- function(options = list(), col_specs, digits = 1, lo #' @export dt2_format_time_format <- function(options = list(), col_specs, from = NULL, to = "L", locale = "pt-br") { - `%||%` <- function(a, b) if (is.null(a)) b else a - if (is.character(col_specs)) col_specs <- match(col_specs, options$columns) + col_specs <- .dt2_name_to_idx(col_specs, options) # ativa locale no cliente (fallback para renders que usem moment direto) options$`_momentLocale` <- locale # DataTables v2: DataTable.render.datetime(from, to, locale) renderer_call <- if (is.null(from)) { - sprintf("DataTable.render.datetime('%s','%s')", to, locale) + sprintf("DataTable.render.datetime(%s,%s)", .dt2_js_str(to), .dt2_js_str(locale)) } else { - sprintf("DataTable.render.datetime('%s','%s','%s')", from, to, locale) + sprintf("DataTable.render.datetime(%s,%s,%s)", + .dt2_js_str(from), .dt2_js_str(to), .dt2_js_str(locale)) } render <- htmlwidgets::JS(renderer_call) @@ -232,8 +225,7 @@ dt2_format_time_format <- function(options = list(), col_specs, #' @return The modified `options` list with an updated `columnDefs` entry. #' @export dt2_format_time_relative <- function(options = list(), col_specs, locale = "pt-br") { - `%||%` <- function(a, b) if (is.null(a)) b else a - if (is.character(col_specs)) col_specs <- match(col_specs, options$columns) + col_specs <- .dt2_name_to_idx(col_specs, options) # ativa locale no cliente (usado por dt2.js) options$`_momentLocale` <- locale diff --git a/R/dt2_html.R b/R/dt2_html.R index 76405ed..e20f29d 100644 --- a/R/dt2_html.R +++ b/R/dt2_html.R @@ -8,7 +8,7 @@ #' @return Updated `options`. #' @export dt2_cols_html <- function(options = list(), cols, js_render) { - if (is.character(cols)) cols <- match(cols, options$columns) + cols <- .dt2_name_to_idx(cols, options) cds <- lapply(cols, function(i) list( targets = i - 1L, render = js_render @@ -25,7 +25,7 @@ dt2_cols_html <- function(options = list(), cols, js_render) { #' @return Updated `options`. #' @export dt2_col_template <- function(options = list(), col, template) { - if (is.character(col)) col <- match(col, options$columns) + col <- .dt2_name_to_idx(col, options) js <- htmlwidgets::JS( sprintf( "function(d,t,row,meta){ if(t!=='display') return d; var html=%s; return html.replace(/\\{\\{VAL\\}\\}/g, d); }", diff --git a/R/dt2_inputs.R b/R/dt2_inputs.R index 887b664..f633ec3 100644 --- a/R/dt2_inputs.R +++ b/R/dt2_inputs.R @@ -7,7 +7,7 @@ #' @return Updated `options`. #' @export dt2_col_checkbox <- function(options = list(), col, input_id_prefix = "row_chk_", value_col = NULL) { - if (is.character(col)) col <- match(col, options$columns) + col <- .dt2_name_to_idx(col, options) # HÍBRIDO: lê por índice (array) OU por nome (objeto) if (is.null(value_col)) { @@ -45,7 +45,7 @@ dt2_col_checkbox <- function(options = list(), col, input_id_prefix = "row_chk_" #' @return Updated `options`. #' @export dt2_col_button <- function(options = list(), col, label = "Action", input_id_prefix = "row_btn_") { - if (is.character(col)) col <- match(col, options$columns) + col <- .dt2_name_to_idx(col, options) js <- htmlwidgets::JS(sprintf( "function(d,t,row,meta){ if(t!=='display') return d; var rid = '%s' + (meta.row+1); diff --git a/R/dt2_options.R b/R/dt2_options.R index 46bb360..b2c0f58 100644 --- a/R/dt2_options.R +++ b/R/dt2_options.R @@ -3,9 +3,13 @@ #' @param ... Vectors like `c(col, "asc"/"desc")`. `col` may be name or 1-based index. #' @return Updated `options`. #' @export +#' @examples +#' opts <- list(columns = names(mtcars)) +#' opts <- dt2_order(opts, c("mpg", "desc")) +#' dt2(mtcars, options = opts) dt2_order <- function(options = list(), ...) { ord <- lapply(list(...), function(x) { - idx <- if (is.character(x[[1]])) match(x[[1]], options$columns) else as.integer(x[[1]]) + idx <- .dt2_name_to_idx(x[[1]], options) list(idx - 1L, x[[2]]) }) options$order <- ord @@ -18,6 +22,9 @@ dt2_order <- function(options = list(), ...) { #' @param regex,smart,caseInsensitive Search flags. #' @return Updated `options`. #' @export +#' @examples +#' opts <- dt2_search_global(list(), value = "Toyota") +#' dt2(mtcars, options = opts) dt2_search_global <- function(options = list(), value, regex = FALSE, smart = TRUE, caseInsensitive = TRUE) { options$search <- list(value = value, regex = regex, smart = smart, caseInsensitive = caseInsensitive) options @@ -36,7 +43,12 @@ dt2_search_global <- function(options = list(), value, regex = FALSE, smart = TR #' If `NULL`, uses the theme default (`"btn btn-sm btn-outline-secondary"`). #' Applied per-button via `className`. #' @return Updated `options`. +#' @seealso [dt2_buttons()] for a lower-level variant that takes full button +#' objects and can relocate the buttons container to a custom CSS selector. #' @export +#' @examples +#' opts <- dt2_use_buttons(list(), buttons = c("copy", "csv", "excel")) +#' dt2(mtcars, options = opts) dt2_use_buttons <- function(options = list(), buttons = c("copy","csv","excel","print"), position = "topEnd", @@ -60,6 +72,14 @@ dt2_use_buttons <- function(options = list(), #' @param lang_url URL to a JSON translation file. #' @return Updated `options`. #' @export +#' @examples +#' # Inline translation +#' opts <- dt2_language(list(), lang_list = list(search = "Buscar:")) +#' dt2(iris, options = opts) +#' +#' # Or load a ready-made translation file from the DataTables CDN +#' opts <- dt2_language(list(), +#' lang_url = "https://cdn.datatables.net/plug-ins/2.3.3/i18n/pt-BR.json") dt2_language <- function(options = list(), lang_list = NULL, lang_url = NULL) { if (!is.null(lang_url)) { options$language <- list(url = lang_url) @@ -77,7 +97,7 @@ dt2_language <- function(options = list(), lang_list = NULL, lang_url = NULL) { dt2_cols_width <- function(options = list(), map_named) { options$columnDefs <- c(options$columnDefs %||% list(), lapply(names(map_named), function(nm) { - i <- match(nm, options$columns) + i <- .dt2_name_to_idx(nm, options) list(targets = i-1L, width = unname(map_named[[nm]])) }) ) @@ -92,7 +112,7 @@ dt2_cols_width <- function(options = list(), map_named) { #' @export dt2_cols_align <- function(options = list(), cols, align = c("left","center","right")) { align <- match.arg(align) - idx <- if (is.character(cols)) match(cols, options$columns) else as.integer(cols) + idx <- .dt2_name_to_idx(cols, options) cls <- switch(align, left="text-start", center="text-center", right="text-end") options$columnDefs <- c(options$columnDefs %||% list(), lapply(idx, function(i) list(targets = i-1L, className = cls)) @@ -106,7 +126,7 @@ dt2_cols_align <- function(options = list(), cols, align = c("left","center","ri #' @return Updated `options`. #' @export dt2_cols_hide <- function(options = list(), cols) { - idx <- if (is.character(cols)) match(cols, options$columns) else as.integer(cols) + idx <- .dt2_name_to_idx(cols, options) options$columnDefs <- c(options$columnDefs %||% list(), lapply(idx, function(i) list(targets = i-1L, visible = FALSE)) ) @@ -114,15 +134,29 @@ dt2_cols_hide <- function(options = list(), cols) { } #' Escape/unescape columns content +#' +#' Controls whether cell content is HTML-escaped before display. #' @param options Options list. #' @param cols Names or indices. -#' @param escape If FALSE, tells DT to trust HTML (use with care). +#' @param escape If `TRUE` (default), HTML special characters are escaped so the +#' raw text is shown. If `FALSE`, the content is rendered as raw HTML +#' (use with care; only for trusted content). #' @return Updated `options`. #' @export dt2_cols_escape <- function(options = list(), cols, escape = TRUE) { - idx <- if (is.character(cols)) match(cols, options$columns) else as.integer(cols) + idx <- .dt2_name_to_idx(cols, options) + render <- if (escape) { + htmlwidgets::JS( + "function(d,t){ if(t!=='display'||d==null) return d;", + " return String(d)", + " .replace(/&/g,'&').replace(//g,'>')", + " .replace(/\"/g,'"').replace(/'/g,'''); }" + ) + } else { + htmlwidgets::JS("function(d,t){return d;}") + } options$columnDefs <- c(options$columnDefs %||% list(), - lapply(idx, function(i) list(targets = i-1L, render = if (escape) htmlwidgets::JS("function(d,t){return d;}") else htmlwidgets::JS("function(d,t){return d;}"))) + lapply(idx, function(i) list(targets = i - 1L, render = render)) ) options } diff --git a/R/dt2_server_processing.R b/R/dt2_server_processing.R index 1ad4782..3e02ef0 100644 --- a/R/dt2_server_processing.R +++ b/R/dt2_server_processing.R @@ -9,7 +9,12 @@ parts <- strsplit(kv, "=", fixed = TRUE) parts <- lapply(parts, function(x) utils::URLdecode(if (length(x) == 2) x[2] else "")) - keys <- vapply(strsplit(kv, "=", fixed = TRUE), `[[`, character(1), 1) + # Keys must be URL-decoded too: dt2.js encodes them with encodeURIComponent, + # so e.g. "search[value]" arrives as "search%5Bvalue%5D" and bracketed + # order keys as "order%5B0%5D%5Bcolumn%5D". Decoding here lets the lookups + # below (q[["search[value]"]], "order[i][column]") match. + keys <- vapply(strsplit(kv, "=", fixed = TRUE), + function(x) utils::URLdecode(x[[1]]), character(1)) q <- stats::setNames(parts, keys) num <- function(x, default = NA_integer_) { diff --git a/R/dt2_utils.R b/R/dt2_utils.R index 712513e..4c7c2d2 100644 --- a/R/dt2_utils.R +++ b/R/dt2_utils.R @@ -7,13 +7,38 @@ .dt2_warn <- function(..., .envir = parent.frame()) cli::cli_warn(c("!" = ...), .envir = .envir) .dt2_abort <- function(..., .envir = parent.frame()) cli::cli_abort(c("x" = ...), .envir = .envir) -# resolve columns by names/indices +# Resolve column names OR 1-based indices to 1-based integer indices. +# Unlike a bare match(), this warns loudly instead of silently returning NA +# when `options$columns` is unset or a name does not exist, so the common +# "forgot to set options$columns" footgun is visible rather than silent. #' @keywords internal -.dt2_resolve_cols <- function(data, options, cols) { - if (is.null(cols)) return(integer()) +.dt2_name_to_idx <- function(cols, options) { if (is.numeric(cols)) return(as.integer(cols)) - if (is.character(cols)) return(match(cols, options$columns)) - .dt2_abort("Invalid columns specification. Use column names (character) or indices (numeric).") + if (!is.character(cols)) { + .dt2_abort("Columns must be column names (character) or 1-based indices (numeric).") + } + if (is.null(options$columns)) { + .dt2_warn(paste( + "Column names were passed but {.code options$columns} is unset, so they", + "cannot be resolved. Set {.code options$columns <- names(data)} before the", + "column helpers, or pass 1-based indices instead." + )) + return(rep(NA_integer_, length(cols))) + } + idx <- match(cols, options$columns) + if (anyNA(idx)) { + .dt2_warn("Unknown column name{?s}: {.val {cols[is.na(idx)]}}.") + } + idx +} + +# Render an R scalar as a safe JS string literal (properly quoted and escaped) +# for interpolation into generated JS, instead of sprintf("'%s'", x) which +# breaks when `x` contains a quote. `NULL` becomes `null_as` (e.g. "null"/"undefined"). +#' @keywords internal +.dt2_js_str <- function(x, null_as = "null") { + if (is.null(x)) return(null_as) + as.character(jsonlite::toJSON(x, auto_unbox = TRUE)) } # internal env to hold named renderers @@ -44,8 +69,7 @@ dt2_register_renderer <- function(name, js) { #' @return Modified \code{options}. #' @export dt2_use_renderer <- function(options = list(), col_specs, name) { - #`%||%` <- function(a, b) if (is.null(a)) b else a - if (is.character(col_specs)) col_specs <- match(col_specs, options$columns) + col_specs <- .dt2_name_to_idx(col_specs, options) js <- get0(name, envir = .dt2_renderers, inherits = FALSE) if (is.null(js)) stop(sprintf("Renderer '%s' is not registered.", name), call. = FALSE) diff --git a/README.md b/README.md index 91c7e9b..91ff201 100644 --- a/README.md +++ b/README.md @@ -1,11 +1,11 @@ -# DT2 +# DT2 -[![R-CMD-check](https://img.shields.io/badge/R--CMD--check-passing-brightgreen)](https://github.com/StrategicProjects/DT2) +[![R-CMD-check](https://github.com/StrategicProjects/DT2/actions/workflows/R-CMD-check.yaml/badge.svg)](https://github.com/StrategicProjects/DT2/actions/workflows/R-CMD-check.yaml) ![CRAN_Status_Badge](https://www.r-pkg.org/badges/version/DT2) ![CRAN Downloads](https://cranlogs.r-pkg.org/badges/grand-total/DT2) ![License](https://img.shields.io/badge/license-MIT-darkviolet.svg) -![Devel Badge](https://img.shields.io/badge/devel%20version-0.1.0-blue.svg) +![Devel Badge](https://img.shields.io/badge/devel%20version-0.1.1-blue.svg) > **DataTables v2 for R** — modular, lightweight, works with or without Shiny. @@ -33,7 +33,7 @@ bridge between R / htmlwidgets and the JavaScript API. |---|---|---| | [DataTables](https://datatables.net/) core + extensions | SpryMedia Ltd | MIT | | [Bootstrap 5](https://getbootstrap.com/) integration | DataTables / Bootstrap | MIT | -| DT2 R package | André Leite, Hugo Medeiros, Diogo Bezerra | MIT | +| DT2 R package | André Leite, Marcos Wasilew, Hugo Vasconcelos, Carlos Amorin, Diogo Bezerra | MIT | This package takes inspiration from the original [DT](https://rstudio.github.io/DT/) package by Yihui Xie (RStudio / Posit), @@ -44,7 +44,11 @@ ColumnControl. ## Installation ```r -# From GitHub +# From CRAN +install.packages("DT2") + +# Development version from GitHub +# install.packages("remotes") remotes::install_github("StrategicProjects/DT2") ``` diff --git a/_pkgdown.yml b/_pkgdown.yml index e2b1b17..047e08b 100644 --- a/_pkgdown.yml +++ b/_pkgdown.yml @@ -3,6 +3,9 @@ template: light-switch: false dark-mode: false trailing_slash_redirect: true + # Build-time dependency: the site theme uses {tidytemplate}, which is NOT on + # CRAN. Install it before running pkgdown::build_site(): + # pak::pak("tidyverse/tidytemplate") package: tidytemplate bootstrap: 5 bslib: @@ -50,6 +53,76 @@ navbar: - text: JS Config Translation href: articles/js-config.html +reference: +- title: Core + desc: Create and theme tables. + contents: + - dt2 + - dt2_theme +- title: Columns & rendering + desc: Target columns and control how cells are rendered. + contents: + - dt2_cols_align + - dt2_cols_hide + - dt2_cols_width + - dt2_cols_escape + - dt2_cols_html + - dt2_col_template + - dt2_cols_render_js + - dt2_cols_render_orthogonal + - dt2_register_renderer + - dt2_use_renderer +- title: Formatting + desc: Numbers, dates and relative times. + contents: + - dt2_format_number + - dt2_format_number_abbrev + - dt2_format_datetime + - dt2_format_time_format + - dt2_format_time_relative +- title: Options helpers + desc: Build the DataTables options list. + contents: + - dt2_order + - dt2_search_global + - dt2_length_menu + - dt2_language + - dt2_use_buttons + - dt2_buttons +- title: Inline inputs + desc: Per-row widgets for Shiny. + contents: + - dt2_col_checkbox + - dt2_col_button +- title: Shiny + desc: Output, render and events. + contents: + - dt2_output + - render_dt2 + - observe_dt2_events + - dt2_state +- title: Shiny proxy + desc: Update a live table without re-rendering. + contents: + - dt2_proxy + - dt2_replace_data + - dt2_draw + - dt2_proxy_order + - dt2_proxy_search + - dt2_proxy_page + - dt2_select_rows +- title: Server-side processing + desc: Stream large data from the server. + contents: + - dt2_bind_server + - dt2_ssp_handler +- title: Extensions & maintenance + desc: Inspect and update bundled libraries. + contents: + - dt2_extensions + - dt2_check_updates + - dt2_update_libs + articles: - title: Getting Started navbar: ~ @@ -77,7 +150,8 @@ footer: computational document processing
developed at the [Secretaria Executiva de Monitoramento Estratégico](https://monitoramento.sepe.pe.gov.br). developed_by: | - Developed by André Leite, Diogo Bezerra and Hugo Medeiros + Developed by André Leite, Marcos Wasilew, Hugo Vasconcelos, Carlos + Amorin and Diogo Bezerra development: mode: auto diff --git a/man/dt2_buttons.Rd b/man/dt2_buttons.Rd index b85ef97..3bd3b39 100644 --- a/man/dt2_buttons.Rd +++ b/man/dt2_buttons.Rd @@ -27,4 +27,12 @@ Configure DataTables Buttons and (optionally) move them to a custom container } \details{ Requires the \strong{Buttons} extension. For CSV/Excel/PDF you also need \strong{JSZip} and \strong{pdfMake} (incl. \code{vfs_fonts}). + +Prefer \code{\link[=dt2_use_buttons]{dt2_use_buttons()}} for the common case: it takes simple button ids, +styles them with a CSS class, and places them in the layout. Use +\code{dt2_buttons()} when you need full button objects or want to move the +rendered buttons into a custom container via \code{target}. +} +\seealso{ +\code{\link[=dt2_use_buttons]{dt2_use_buttons()}} } diff --git a/man/dt2_cols_escape.Rd b/man/dt2_cols_escape.Rd index 2b043ec..f5068b3 100644 --- a/man/dt2_cols_escape.Rd +++ b/man/dt2_cols_escape.Rd @@ -11,11 +11,13 @@ dt2_cols_escape(options = list(), cols, escape = TRUE) \item{cols}{Names or indices.} -\item{escape}{If FALSE, tells DT to trust HTML (use with care).} +\item{escape}{If \code{TRUE} (default), HTML special characters are escaped so the +raw text is shown. If \code{FALSE}, the content is rendered as raw HTML +(use with care; only for trusted content).} } \value{ Updated \code{options}. } \description{ -Escape/unescape columns content +Controls whether cell content is HTML-escaped before display. } diff --git a/man/dt2_language.Rd b/man/dt2_language.Rd index d9e8b0c..ce0ece8 100644 --- a/man/dt2_language.Rd +++ b/man/dt2_language.Rd @@ -19,3 +19,12 @@ Updated \code{options}. \description{ Language helper (either list or JSON url) } +\examples{ +# Inline translation +opts <- dt2_language(list(), lang_list = list(search = "Buscar:")) +dt2(iris, options = opts) + +# Or load a ready-made translation file from the DataTables CDN +opts <- dt2_language(list(), + lang_url = "https://cdn.datatables.net/plug-ins/2.3.3/i18n/pt-BR.json") +} diff --git a/man/dt2_order.Rd b/man/dt2_order.Rd index 1fe317d..99158fb 100644 --- a/man/dt2_order.Rd +++ b/man/dt2_order.Rd @@ -17,3 +17,8 @@ Updated \code{options}. \description{ Define initial ordering (option \code{order}) } +\examples{ +opts <- list(columns = names(mtcars)) +opts <- dt2_order(opts, c("mpg", "desc")) +dt2(mtcars, options = opts) +} diff --git a/man/dt2_search_global.Rd b/man/dt2_search_global.Rd index fb4010c..2a4bff9 100644 --- a/man/dt2_search_global.Rd +++ b/man/dt2_search_global.Rd @@ -25,3 +25,7 @@ Updated \code{options}. \description{ Set global search (option \code{search}) } +\examples{ +opts <- dt2_search_global(list(), value = "Toyota") +dt2(mtcars, options = opts) +} diff --git a/man/dt2_use_buttons.Rd b/man/dt2_use_buttons.Rd index b779d6c..b25a19a 100644 --- a/man/dt2_use_buttons.Rd +++ b/man/dt2_use_buttons.Rd @@ -30,3 +30,11 @@ Updated \code{options}. \description{ Uses the modern DataTables 2.x \code{layout} API (not the deprecated \code{dom}). } +\examples{ +opts <- dt2_use_buttons(list(), buttons = c("copy", "csv", "excel")) +dt2(mtcars, options = opts) +} +\seealso{ +\code{\link[=dt2_buttons]{dt2_buttons()}} for a lower-level variant that takes full button +objects and can relocate the buttons container to a custom CSS selector. +} diff --git a/man/figures/diagrama-2025-09-11.svg b/man/figures/diagrama-2025-09-11.svg deleted file mode 100644 index 9d7e7c0..0000000 --- a/man/figures/diagrama-2025-09-11.svg +++ /dev/null @@ -1,5 +0,0 @@ - - -DT2 - - diff --git a/man/figures/diagrama-2025-09-11-2.pik b/man/figures/logo.pik similarity index 100% rename from man/figures/diagrama-2025-09-11-2.pik rename to man/figures/logo.pik diff --git a/man/figures/logo.svg b/man/figures/logo.svg new file mode 100644 index 0000000..a989db6 --- /dev/null +++ b/man/figures/logo.svg @@ -0,0 +1,7 @@ + + + + DT2 + diff --git a/man/render_dt2.Rd b/man/render_dt2.Rd index 6643d06..1c33781 100644 --- a/man/render_dt2.Rd +++ b/man/render_dt2.Rd @@ -13,7 +13,7 @@ render_dt2(expr, env = parent.frame(), quoted = FALSE) } \value{ A Shiny render function (closure produced by -\code{\link[htmlwidgets:htmlwidgets-shiny]{htmlwidgets::shinyRenderWidget()}}) that emits a DT2 widget. +\code{\link[htmlwidgets:shinyRenderWidget]{htmlwidgets::shinyRenderWidget()}}) that emits a DT2 widget. } \description{ Render a DT2 table in a Shiny server function. diff --git a/tests/testthat.R b/tests/testthat.R new file mode 100644 index 0000000..e2cc90a --- /dev/null +++ b/tests/testthat.R @@ -0,0 +1,4 @@ +library(testthat) +library(DT2) + +test_check("DT2") diff --git a/tests/testthat/test-cols-escape.R b/tests/testthat/test-cols-escape.R new file mode 100644 index 0000000..1353d18 --- /dev/null +++ b/tests/testthat/test-cols-escape.R @@ -0,0 +1,29 @@ +# Regression tests for dt2_cols_escape(). +# Bug: both branches of `if (escape)` returned the identical identity function +# `function(d,t){return d;}`, so `escape` was a no-op and the default +# (escape = TRUE) rendered raw HTML instead of escaping it. + +test_that("dt2_cols_escape() escapes HTML special chars when escape = TRUE", { + o <- dt2_cols_escape(list(columns = "x"), 1, escape = TRUE) + r <- as.character(o$columnDefs[[1]]$render) + expect_true(grepl("<", r, fixed = TRUE)) + expect_true(grepl(">", r, fixed = TRUE)) + expect_true(grepl("&", r, fixed = TRUE)) + expect_true(grepl(""", r, fixed = TRUE)) +}) + +test_that("dt2_cols_escape() renders raw HTML when escape = FALSE", { + o <- dt2_cols_escape(list(columns = "x"), 1, escape = FALSE) + r <- as.character(o$columnDefs[[1]]$render) + expect_false(grepl("<", r, fixed = TRUE)) +}) + +test_that("dt2_cols_escape() TRUE and FALSE yield different renderers", { + t_render <- as.character( + dt2_cols_escape(list(columns = "x"), 1, escape = TRUE)$columnDefs[[1]]$render + ) + f_render <- as.character( + dt2_cols_escape(list(columns = "x"), 1, escape = FALSE)$columnDefs[[1]]$render + ) + expect_false(identical(t_render, f_render)) +}) diff --git a/tests/testthat/test-columns.R b/tests/testthat/test-columns.R new file mode 100644 index 0000000..c6782bf --- /dev/null +++ b/tests/testthat/test-columns.R @@ -0,0 +1,43 @@ +# Tests for column-name resolution (the options$columns "footgun") and the +# automatic injection of options$columns by dt2(). + +test_that(".dt2_name_to_idx resolves names against options$columns", { + expect_equal(.dt2_name_to_idx(c("b", "c"), list(columns = c("a", "b", "c"))), + c(2L, 3L)) +}) + +test_that(".dt2_name_to_idx passes 1-based numeric indices through", { + expect_equal(.dt2_name_to_idx(c(2, 3), list()), c(2L, 3L)) +}) + +test_that(".dt2_name_to_idx warns (not silent NA) when options$columns is unset", { + expect_warning(idx <- .dt2_name_to_idx("x", list()), "options\\$columns") + expect_true(all(is.na(idx))) +}) + +test_that(".dt2_name_to_idx warns on unknown column names", { + expect_warning(idx <- .dt2_name_to_idx("zzz", list(columns = c("a", "b"))), + "Unknown column") + expect_true(is.na(idx)) +}) + +test_that("name-based helpers warn instead of failing silently", { + expect_warning(dt2_cols_hide(list(), cols = "Species"), "options\\$columns") + expect_warning(dt2_cols_align(list(), "Species"), "options\\$columns") +}) + +test_that("name-based helpers resolve correctly when columns are set", { + o <- dt2_cols_hide(list(columns = c("a", "b", "c")), cols = c("b", "c")) + expect_equal(vapply(o$columnDefs, function(d) d$targets, integer(1)), c(1L, 2L)) +}) + +test_that("dt2() injects options$columns from the data when absent", { + w <- dt2(head(iris, 2)) + expect_equal(w$x$options$columns, names(iris)) +}) + +test_that("dt2() does not override user-provided options$columns", { + cols <- c("A", "B", "C", "D", "E") + w <- dt2(head(iris, 2), options = list(columns = cols)) + expect_equal(w$x$options$columns, cols) +}) diff --git a/tests/testthat/test-js-injection.R b/tests/testthat/test-js-injection.R new file mode 100644 index 0000000..2e8d62b --- /dev/null +++ b/tests/testthat/test-js-injection.R @@ -0,0 +1,33 @@ +# Tests for safe JS string interpolation in the format helpers. +# Previously strings were injected with sprintf("'%s'", x), which produced +# invalid JS when the value itself contained a quote. + +test_that(".dt2_js_str produces valid, escaped JS string literals", { + expect_equal(.dt2_js_str("R$"), '"R$"') + expect_equal(.dt2_js_str("a\"b"), '"a\\"b"') + expect_equal(.dt2_js_str(NULL), "null") + expect_equal(.dt2_js_str(NULL, "undefined"), "undefined") +}) + +test_that("dt2_format_number safely quotes a prefix containing a quote", { + o <- dt2_format_number(list(columns = "x"), 1, prefix = "O'Brien $") + r <- as.character(o$columnDefs[[1]]$render) + # valid double-quoted JS literal ... + expect_true(grepl('"O\'Brien $"', r, fixed = TRUE)) + # ... not the broken single-quoted form + expect_false(grepl("'O'Brien $'", r, fixed = TRUE)) +}) + +test_that("dt2_format_number renders NULL separators as null", { + o <- dt2_format_number(list(columns = "x"), 1, + thousands = NULL, decimal = NULL, digits = 2) + r <- as.character(o$columnDefs[[1]]$render) + expect_true(grepl("render.number(null,null,2", r, fixed = TRUE)) +}) + +test_that("dt2_format_datetime renders NULL args as undefined", { + o <- dt2_format_datetime(list(columns = "x"), 1, from = NULL, to = "DD/MM/YYYY") + r <- as.character(o$columnDefs[[1]]$render) + expect_true(grepl("undefined", r, fixed = TRUE)) + expect_true(grepl('"DD/MM/YYYY"', r, fixed = TRUE)) +}) diff --git a/tests/testthat/test-server-processing.R b/tests/testthat/test-server-processing.R new file mode 100644 index 0000000..2cb6ace --- /dev/null +++ b/tests/testthat/test-server-processing.R @@ -0,0 +1,63 @@ +# Regression tests for the server-side processing (SSP) request parser. +# Bug: dt2.js encodes query-string KEYS with encodeURIComponent +# (e.g. "search[value]" -> "search%5Bvalue%5D"), but the parser only +# URL-decoded the values, never the keys. As a result global search and +# ordering were silently never applied -- only pagination worked. + +# Mirror how dt2.js encodes a key (encodeURIComponent on the key text). +enc <- function(s) utils::URLencode(s, reserved = TRUE) + +ssp_qs <- function(...) paste(..., sep = "&") + +test_that(".dt2_parse_ssp_request decodes encoded search/order keys", { + qs <- ssp_qs( + "draw=2", "start=0", "length=10", + paste0(enc("search[value]"), "=", enc("foo bar")), + paste0(enc("search[regex]"), "=false"), + paste0(enc("order[0][column]"), "=1"), + paste0(enc("order[0][dir]"), "=desc") + ) + pars <- .dt2_parse_ssp_request(list(QUERY_STRING = qs), n_cols = 3) + + expect_equal(pars$draw, 2L) + expect_equal(pars$search$value, "foo bar") # was NULL before the fix + expect_false(pars$search$regex) + expect_length(pars$order, 1) + expect_equal(pars$order[[1]]$column, 2L) # 0-based 1 -> 1-based 2 + expect_equal(pars$order[[1]]$dir, "desc") +}) + +test_that("dt2_ssp_handler applies global search and ordering end-to-end", { + df <- data.frame( + id = 1:5, + name = c("alpha", "beta", "gamma", "Alpha", "BETA"), + stringsAsFactors = FALSE + ) + h <- dt2_ssp_handler(names(df)) + + qs <- ssp_qs( + "draw=1", "start=0", "length=10", + paste0(enc("search[value]"), "=", enc("alpha")), + paste0(enc("order[0][column]"), "=0"), + paste0(enc("order[0][dir]"), "=desc") + ) + out <- h(df, list(QUERY_STRING = qs)) + + expect_equal(out$recordsTotal, 5L) + # case-insensitive global search matches "alpha" and "Alpha" + expect_equal(out$recordsFiltered, 2L) + # ordered by id descending -> first row is id 4 ("Alpha"), not id 1 + expect_equal(out$data[[1]]$id, 4L) +}) + +test_that("dt2_ssp_handler paginates", { + df <- data.frame(id = 1:100) + h <- dt2_ssp_handler(names(df)) + qs <- ssp_qs("draw=1", "start=20", "length=5") + out <- h(df, list(QUERY_STRING = qs)) + + expect_equal(out$recordsTotal, 100L) + expect_equal(out$recordsFiltered, 100L) + expect_equal(length(out$data), 5L) + expect_equal(out$data[[1]]$id, 21L) +}) diff --git a/tests/testthat/test-theme.R b/tests/testthat/test-theme.R new file mode 100644 index 0000000..94e11cc --- /dev/null +++ b/tests/testthat/test-theme.R @@ -0,0 +1,12 @@ +# print.dt2_theme() previously omitted the `class` field. + +test_that("print.dt2_theme() shows the class field", { + expect_output(print(dt2_theme("default")), "class") +}) + +test_that("dt2_theme() applies preset overrides", { + th <- dt2_theme("minimal", striped = TRUE) + expect_s3_class(th, "dt2_theme") + expect_true(th$striped) + expect_false(th$hover) # from the 'minimal' preset +}) diff --git a/vignettes/formatting.Rmd b/vignettes/formatting.Rmd index 2b9acc9..b15874a 100644 --- a/vignettes/formatting.Rmd +++ b/vignettes/formatting.Rmd @@ -36,6 +36,14 @@ dt2(df, options = opts) The `col_specs` argument accepts column names (matching `options$columns`) or 1-based integer indices. +> **Why `columns = names(df)`?** When you refer to columns *by name*, DT2 needs +> to know the column order to translate names into positions. That list lives in +> `options$columns`, so set it once (`opts <- list(columns = names(df))`) before +> calling any name-based helper. If you skip it, DT2 emits a warning and the +> target cannot be resolved — pass 1-based indices instead if you prefer not to +> set it. (`dt2()` itself fills `options$columns` from the data for rendering, +> but the helpers run *before* `dt2()`, so they need it set explicitly.) + ## Number Formatting `dt2_format_number()` uses DataTables' built-in `DataTable.render.number()`: diff --git a/vignettes/getting-started.Rmd b/vignettes/getting-started.Rmd index f2bf588..4888623 100644 --- a/vignettes/getting-started.Rmd +++ b/vignettes/getting-started.Rmd @@ -558,6 +558,10 @@ and suffixes. These modify your options list in-place and generate the correct JavaScript `render` functions behind the scenes, so you don't need to write any JS: +Note the `opts <- list(columns = names(mtcars))` line: name-based helpers +resolve column names against `options$columns`, so set it once before calling +them (or pass 1-based indices). DT2 warns if it is missing. + ```{r} opts <- list(columns = names(mtcars)) opts <- dt2_format_number(opts, "hp", thousands = ",", digits = 0)