Skip to contents

Introduction

The ntrd package is designed to be extensible. While it ships with built-in data sources (CSV upload and a demo dataset), the real power comes from extension packages that add new data sources — pulling data from REDCap, institutional databases, or any other system accessible via API.

Extensions are regular R packages that:

  1. Declare themselves as ntrd extensions via a DESCRIPTION field
  2. Define a new data source class using S7
  3. Implement three S7 generic methods: data_source_ui(), data_source_server(), and data_load()
  4. Optionally provide additional UI, configuration persistence, and scoring defaults

The ntrdWisconsin package is a full working example and is referenced throughout this guide.

Custom Scores

One can also define custom neuropsychological score classes in an extension package. The ntrs package provides the new_npsych_scores() function for this purpose, and the ntrd app picks up any S7 classes that inherit from ntrs::npsych_scores automatically via namespace scanning. For more information, see the Extending Scores and Versions vignette in the ntrs package for details, and the ntrdWisconsin package for a concrete example of defining a custom CESDTOTAL score class.

How extensions are discovered

When the ntrd Shiny app starts, it calls discover_data_sources(), which triggers the following sequence:

  1. Scan: load_extensions() iterates over all installed packages (via .libPaths()) and reads each package’s DESCRIPTION file looking for Config/ntrd/extension: true.
  2. Load: For each extension found, its namespace is loaded via loadNamespace(), which triggers .onLoad() and registers S7 methods.
  3. Introspect: discover_data_sources() inspects the registered methods on the data_source_server and data_source_ui generics, instantiates each data source class, and returns a named list of data_source objects.
  4. Render: The Shiny app presents all discovered data sources in a dropdown for the user to choose from.

Package setup

DESCRIPTION

Your extension package must include two things in its DESCRIPTION:

  1. The extension declaration field
  2. ntrd and S7 as imports
# DESCRIPTION
Package: myExtension
Title: My ntrd Extension
Version: 0.1.0
Imports:
    ntrd,
    S7,
    shiny
Config/ntrd/extension: true

Note

The field name is exactly Config/ntrd/extension and the value must be true (lowercase).

Here is the relevant section from ntrdWisconsin’s DESCRIPTION:

Imports:
    S7,
    ntrd,
    ntrs,
    ntrsTscores,
    shiny,
    ...
Remotes:
    rmtrane/ntrd,
    rmtrane/ntrs,
    rmtrane/ntrsTscores
Config/ntrd/extension: true

Note that ntrdWisconsin also imports ntrs and ntrsTscores because it defines a custom score class and sets T-score defaults. Your extension only needs to import what it uses. Also, note that the Remotes field is necessary for dependencies that are not on CRAN (which none of these packages are yet).

.onLoad hook

Your package must call S7::methods_register() in .onLoad() so that your S7 method implementations are registered when the namespace is loaded:

# R/zzz.R
.onLoad <- function(...) {
  S7::methods_register()
}

This is standard practice for any package defining S7 methods for external generics.

Defining a data source class

Use new_data_source() to create an S7 class that inherits from ntrd::data_source. You provide three arguments:

  • name: A human-readable display name (shown in the data source dropdown)
  • id: A unique identifier string (used internally as a key)
  • package: The name of the package defining this data source
# R/my_source.R
my_source <- ntrd::new_data_source(
  name = "My Data Source",
  id = "my_source",
  package = "myExtension"
)

This returns a class (not an instance). Calling my_source() produces an instance with the name and id properties set.

For reference, here is how ntrdWisconsin defines its source class:

# ntrdWisconsin/R/wadrc_source.R
wadrc_source <- ntrd::new_data_source(
  name = "Wisconsin ADRC",
  id = "wadrc_redcap",
  package = "ntrdWisconsin"
)

Tip

For advanced use cases (e.g., adding custom S7 properties), you can still use S7::new_class() directly with parent = ntrd::data_source.

Implementing the required generics

Your extension must provide S7 methods for three generics exported by ntrd. Each method file should declare the external generic using S7::new_external_generic():

data_source_ui <- S7::new_external_generic("ntrd", "data_source_ui", "source")
data_source_server <- S7::new_external_generic("ntrd", "data_source_server", "source")
data_load <- S7::new_external_generic("ntrd", "data_load", "source")

data_source_ui(source, ns)

Returns a Shiny UI element (tagList) that will be rendered when the user selects your data source. The ns argument is a namespace function — use it to wrap all input IDs.

S7::method(data_source_ui, my_source) <- function(source, ns) {
  shiny::tagList(
    shiny::passwordInput(
      inputId = ns("api_token"),
      label = "API Token"
    )
  )
}

data_source_server(source, id)

Returns a Shiny module server (via shiny::moduleServer()) that must return a list with the following elements:

Element Required Description
params Yes A shiny::reactive() returning a named list of parameters. These are passed as arguments to data_load() when the user clicks “Go”. Use shiny::req() to ensure required inputs are present.
session Yes The Shiny session object.
restore No A function that accepts a params list and restores input values (for configuration persistence).
extras No A shiny::reactiveValues() object for passing additional data/UI to the main app (see Extension UI).
S7::method(data_source_server, my_source) <- function(source, id) {
  shiny::moduleServer(id, function(input, output, session) {
    params <- shiny::reactive({
      shiny::req(input$api_token)
      list(api_token = input$api_token)
    })

    restore <- function(params) {
      shiny::updateTextInput(session, "api_token", value = params$api_token)
    }

    list(
      params = params,
      restore = restore,
      session = session
    )
  })
}

data_load(source, ...)

This is where the actual data retrieval happens. The function receives the data source object and the parameters from $params() (unpacked via do.call()). It must return a data_nacc object.

S7::method(data_load, my_source) <- function(source, api_token, ...) {
  # Fetch data using your API
  raw_data <- fetch_from_my_api(api_token)

  # Transform to NACC format and validate
  ntrd::data_nacc(data = raw_data)
}

When no user configuration is needed

If your data source doesn’t require any user input (e.g., it connects to a database with preconfigured credentials, or loads a bundled dataset), the generics simplify considerably. The built-in demo_source follows this pattern:

  • data_source_ui returns informational text instead of input widgets
  • data_source_server returns an empty params list (reactive(list()))
  • data_load takes only source (no extra arguments)
S7::method(data_source_ui, my_source) <- function(source, ns) {
  shiny::p("Click 'Go' to load data. No configuration needed.")
}

S7::method(data_source_server, my_source) <- function(source, id) {
  shiny::moduleServer(id, function(input, output, session) {
    list(params = shiny::reactive(list()), session = session)
  })
}

S7::method(data_load, my_source) <- function(source, ...) {
  raw <- fetch_data_from_preconfigured_source()
  ntrd::data_nacc(data = raw)
}

This works because params() returns an empty list, and the app calls data_load via do.call(data_load, c(source = source, list())), which resolves to data_load(source).

The data_nacc contract

All data sources must return a data_nacc object from data_load(). This S7 class validates the data on construction, ensuring it meets the expected format.

Required columns

Column Type Description
NACCID character Unique participant identifier
SEX numeric 1 = Male, 2 = Female, 8 = Prefer not to answer, 9 = Don’t know, or NA
EDUC numeric Years of education
BIRTHYR numeric Year of birth
BIRTHMO numeric Month of birth (1–12)

Any additional columns are passed through. Logical columns are coerced to numeric.

Date fields

Either provide:

  • VISITYR (numeric), VISITMO (numeric, 1–12), and VISITDAY (numeric, 1–31)

or:

  • VISITDATE (Date, "YYYY-MM-DD" format)

If VISITYR, VISITMO, and VISITDAY are provided, VISITDATE is computed automatically and the year/month/day columns are removed.

Computed fields

  • NACCAGE is auto-computed from VISITDATE and BIRTHYR/BIRTHMO if not already present.

Additional columns

Any additional columns in the data (neuropsych scores, diagnostic variables, etc.) are preserved and can be used by the app.

Optional: Configuration persistence

If your data_source_server returns a $restore function, the ntrd app will offer users the option to save and load their configuration (e.g., API tokens).

Configurations are saved using the safer package (encrypted on disk) in tools::R_user_dir("ntrd", "config") with the filename {source_id}.bin.

The $restore function receives the same named list that $params() returns and should update the Shiny inputs accordingly:

restore <- function(params) {
  shiny::updateTextInput(session, "api_token", value = params$api_token)
}

Optional: Extension UI via $extras

Extensions can inject additional UI into the main ntrd app via the $extras reactive values in data_source_server. This is useful for features that go beyond data loading, such as displaying biomarker results.

To provide extension UI, include extension_ui and extension_server in your extras reactive values:

extras <- shiny::reactiveValues(
  extension_ui = function() my_extension_ui(id = "my-module"),
  extension_server = function(ptid, extras) {
    my_extension_server(id = "my-module", ptid, extras)
  }
)

The result of the extension_ui function must be a bslib::nav_panel or a list thereof.

The ntrd app will:

  1. Call extras$extension_ui() to render additional UI
  2. Call extras$extension_server(ptid, extras) with the selected participant ID

You can also pass arbitrary data through extras (e.g., API tokens, cached query results) that your extension server can consume.

In ntrdWisconsin, this mechanism is used to display biomarker data fetched from the Panda API:

extras <- shiny::reactiveValues(
  all_values = NULL,
  panda_api_token = NULL,
  extension_ui = \() extension_ui(id = "biomarker-tables"),
  extension_server = \(ptid, extras) {
    extension_server(id = "biomarker-tables", ptid, extras)
  }
)

Optional: Setting scoring defaults

When a data source is selected and the user clicks “Go”, ntrd uses the package property of the data source to identify the extension package and checks whether it defines a .set_defaults() function. If it does, it is called to set standardization method defaults.

Define this function in your package (it does not need to be exported):

# R/zzz.R
.set_defaults <- function() {
  ntrs::set_std_defaults(ntrs::WAIS(), "tscores", overwrite = TRUE)
  ntrs::set_std_defaults(ntrs::REY1REC(), "tscores", overwrite = TRUE)
  # ... additional defaults
}

Note

In ntrdWisconsin, .set_defaults() is also called from .onAttach() so that defaults are set both when the package is attached directly and when the ntrd app activates the extension. Also, note that the T-scores referenced by ntrdWisconsin are found in the ntrsTscores package.

Optional: In-app updates

You can opt in to ntrd’s in-app update mechanism. When a user selects your data source, ntrd checks whether a newer version of your package is available and, if so, shows an update banner in the header with an “Update” button and an optional a “What’s new?” link.

Opt-in: two exported functions

To opt in, export two functions from your package’s namespace, with these exact names:

  • ntrd_update_available() — returns an update_result describing whether an update is available.
  • ntrd_update_extension() — performs the install. Runs in a restarted R session; your extension’s namespace is not loaded when it runs.

If only one of the two is exported, ntrd emits a one-time warning per session and disables in-app updates for the extension.

The easy path: GitHub-hosted extensions

If your extension is hosted on GitHub, ntrd provides two factory functions that produce ready-to-use implementations. The entire opt-in is then four lines:

# R/updates.R

#' @export
ntrd_update_available <- ntrd::default_github_update_available(
  "yourname/myExtension"
)

#' @export
ntrd_update_extension <- ntrd::default_github_update_extension(
  "yourname/myExtension"
)

The factories:

  • Compare the locally installed version against the Version: field in the DESCRIPTION on the repo’s default branch (or a pinned ref — see below).
  • Construct an update_result whose news_url points at https://github.com/<repo>/blob/HEAD/NEWS.md, so the update banner’s “What’s new?” link works automatically if you maintain a NEWS.md.
  • Install with remotes::install_github(..., upgrade = "always", dependencies = TRUE), so ntrd and other dependencies are also brought up to date.

If your package name differs from the repo name, pass package = explicitly to default_github_update_available(). You can also pin a specific ref with the "user/repo@ref" syntax.

ntrdWisconsin/R/updates.R uses this pattern verbatim.

The update_result contract

Both your custom and the factory-produced implementations of ntrd_update_available() must return an update_result S7 object:

ntrd::update_result(
  available = TRUE,
  current   = "0.1.0",
  latest    = "0.2.0",
  news_url  = "https://github.com/user/repo/blob/HEAD/NEWS.md"
)
Property Type Default Meaning
available logical scalar FALSE Whether a newer version exists. The safe FALSE default means a forgotten field never produces a spurious update prompt.
current character scalar NA_character_ Installed version, or NA if it could not be determined.
latest character scalar NA_character_ Latest available version, or NA if it could not be determined.
news_url character scalar NA_character_ Optional URL to a changelog. Use NA_character_ (not NULL or "") for “absent”.

update_result() with no arguments is a valid “no update available” object — use it as a safe fallback when your check can’t complete.

Writing a custom update check

If you’re not on GitHub (GitLab, an internal CRAN-like, etc.) you’ll write your own implementations. The contract is just the two functions and the update_result return type:

#' @export
ntrd_update_available <- function() {
  current <- tryCatch(
    as.character(utils::packageVersion("myExtension")),
    error = function(e) NA_character_
  )
  latest <- tryCatch(
    fetch_latest_version_from_my_server(),  # your logic
    error = function(e) NA_character_
  )

  ntrd::update_result(
    available = !is.na(current) && !is.na(latest) &&
                utils::compareVersion(latest, current) > 0,
    current   = current,
    latest    = latest,
    news_url  = "https://example.com/myExtension/CHANGELOG.html"
  )
}

#' @export
ntrd_update_extension <- function() {
  install.packages("myExtension", repos = "https://example.com/cran")
  invisible(NULL)
}

ntrd_update_available() is invoked from a Shiny reactive context; the framework wraps it in error handling so a thrown error becomes a warning plus the safe “no update” fallback, but it’s still good practice to wrap network calls in tryCatch. ntrd_update_extension() runs in a restarted session and its return value is interpreted as success/failure based on whether it threw an error.

Caching and forced refresh

Update checks are cached per package for one hour by default. The Shiny app calls ntrd::check_extension_update(package) internally. If you need to invalidate the cache (for example, in a “check again” affordance or during testing), use ntrd::clear_update_cache(package) or ntrd::clear_update_cache() for everything.

Platform support

The two-stage restart cascade — install in one fresh R session, then relaunch in the next — depends on rstudioapi::restartSession(command = ...). Only RStudio honors the command argument fully. In Positron, plain R, Rscript, and VS Code’s R extension, the update modal shows manual install instructions instead. Extensions don’t need to do anything different to support either path; the framework picks the right flow automatically.

NEWS.md convention

When using the GitHub factories, the default news_url points at NEWS.md on the repo’s default branch. Maintaining a NEWS.md gives your users a “What’s new?” link in the update banner. Standard # myExtension 0.2.0 / ## Section Markdown is rendered by GitHub as-is.

Conflict detection

If multiple extension packages define S7 generics with the same std_using_* name, ntrd will emit a warning at startup. Extension authors should use shared generics from a common dependency (e.g., ntrs) rather than redefining them.

Complete example: ntrdWisconsin

Here is a summary of the key files in the ntrdWisconsin package and how they map to the extension API:

File Purpose
DESCRIPTION Declares Config/ntrd/extension: true; imports ntrd, ntrs, ntrsTscores, S7, …; uses Remotes: for the GitHub-hosted dependencies.
R/zzz.R .onLoad() calls S7::methods_register(); .onAttach() calls .set_defaults() so T-score defaults apply when the package is attached directly (in addition to when the ntrd app activates the extension).
R/wadrc_source.R Defines wadrc_source class (extends ntrd::data_source).
R/data_source_ui.R Implements data_source_ui for wadrc_source — REDCap token inputs.
R/data_source_server.R Implements data_source_server for wadrc_source — collects tokens; provides $restore and $extras.
R/data_load.R Implements data_load for wadrc_source — pulls from REDCap, returns data_nacc.
R/extensionModule.R Defines extension_ui() / extension_server() for biomarker display (passed via $extras).
R/updates.R Exports ntrd_update_available() and ntrd_update_extension() via the GitHub factories — opts the package in to in-app updates (see In-app updates).
R/npsych_scores-CESDTOTAL.R Defines a custom CESDTOTAL score subclass via ntrs::new_npsych_scores() — picked up automatically by ntrs’s namespace-scanning discovery.
R/ntrdWisconsin-package.R Package-level docs; @import ntrsTscores so T-score methods are attached when ntrdWisconsin is loaded.

Minimal skeleton

To create a new extension from scratch, you need at minimum four files:

R/my_source.R

my_source <- ntrd::new_data_source(name = "My Source", id = "my_source", package = "myExtension")

R/data_source_ui.R

data_source_ui <- S7::new_external_generic("ntrd", "data_source_ui", "source")

S7::method(data_source_ui, my_source) <- function(source, ns) {
  shiny::tagList(
    shiny::textInput(ns("token"), "API Token")
  )
}

R/data_source_server.R

data_source_server <- S7::new_external_generic("ntrd", "data_source_server", "source")

S7::method(data_source_server, my_source) <- function(source, id) {
  shiny::moduleServer(id, function(input, output, session) {
    params <- shiny::reactive({
      shiny::req(input$token)
      list(token = input$token)
    })

    list(
      params = params,
      session = session
    )
  })
}

R/data_load.R

#' @include my_source.R
data_load <- S7::new_external_generic("ntrd", "data_load", "source")

S7::method(data_load, my_source) <- function(source, token, ...) {
  raw <- my_api_fetch(token)
  ntrd::data_nacc(data = raw)
}

R/zzz.R

.onLoad <- function(...) {
  S7::methods_register()
}

Important

Make sure your source class file is loaded before the method files. Use @include roxygen tags or the Collate field in DESCRIPTION to control file ordering.