2024-12-19 11:32:09 +01:00
#' Shiny module to browser and export REDCap data
#'
#' @param id Namespace id
#' @param include_title logical to include title
#'
#' @rdname redcap_read_shiny_module
#'
#' @return shiny ui element
#' @export
2025-04-03 13:11:02 +02:00
m_redcap_readUI <- function ( id , title = TRUE , url = NULL ) {
2024-12-19 11:32:09 +01:00
ns <- shiny :: NS ( id )
2025-03-13 12:41:50 +01:00
if ( isTRUE ( title ) ) {
2026-03-27 21:56:57 +01:00
title <- shiny :: tags $ h4 ( i18n $ t ( " Import data from REDCap" ) , class = " redcap-module-title" )
2025-03-13 12:41:50 +01:00
}
2025-01-15 16:21:38 +01:00
server_ui <- shiny :: tagList (
2025-09-25 10:07:19 +02:00
shiny :: tags $ h4 ( i18n $ t ( " REDCap server" ) ) ,
2024-12-19 11:32:09 +01:00
shiny :: textInput (
inputId = ns ( " uri" ) ,
2025-09-25 10:07:19 +02:00
label = i18n $ t ( " Web address" ) ,
2025-06-25 10:49:34 +02:00
value = if_not_missing ( url , " https://redcap.your.institution/" ) ,
width = " 100%"
2024-12-19 11:32:09 +01:00
) ,
2026-03-27 21:56:57 +01:00
shiny :: helpText (
i18n $ t (
" Format should be either 'https://redcap.your.institution/' or 'https://your.institution/redcap/'"
)
) ,
2025-10-27 10:28:22 +01:00
shiny :: br ( ) ,
shiny :: br ( ) ,
2025-06-25 10:49:34 +02:00
shiny :: passwordInput (
2024-12-19 11:32:09 +01:00
inputId = ns ( " api" ) ,
2025-09-25 10:07:19 +02:00
label = i18n $ t ( " API token" ) ,
2025-06-25 10:49:34 +02:00
value = " " ,
width = " 100%"
2025-02-26 21:09:08 +01:00
) ,
2026-03-27 21:56:57 +01:00
shiny :: helpText ( i18n $ t (
" The token is a string of 32 numbers and letters."
) ) ,
2025-04-03 13:11:02 +02:00
shiny :: br ( ) ,
shiny :: br ( ) ,
2025-02-27 13:34:45 +01:00
shiny :: actionButton (
inputId = ns ( " data_connect" ) ,
2025-09-25 10:07:19 +02:00
label = i18n $ t ( " Connect" ) ,
2026-04-01 23:41:23 +02:00
icon = phosphoricons :: ph ( " link" , weight = " bold" ) ,
# icon = shiny::icon("link", lib = "glyphicon"),
2025-04-03 13:11:02 +02:00
width = " 100%" ,
2025-02-27 13:34:45 +01:00
disabled = TRUE
) ,
shiny :: br ( ) ,
shiny :: br ( ) ,
2025-02-26 21:09:08 +01:00
tags $ div (
id = ns ( " connect-placeholder" ) ,
shinyWidgets :: alert (
id = ns ( " connect-result" ) ,
status = " info" ,
2026-03-27 21:56:57 +01:00
tags $ p (
phosphoricons :: ph ( " info" , weight = " bold" ) ,
i18n $ t ( " Please fill in web address and API token, then press 'Connect'." )
)
2025-02-26 21:09:08 +01:00
) ,
dismissible = TRUE
2025-02-27 13:34:45 +01:00
) ,
shiny :: br ( )
2024-12-19 11:32:09 +01:00
)
2025-04-03 13:11:02 +02:00
filter_ui <-
shiny :: tagList (
# width = 6,
shiny :: uiOutput ( outputId = ns ( " arms" ) ) ,
shiny :: textInput (
inputId = ns ( " filter" ) ,
2026-03-27 21:56:57 +01:00
label = i18n $ t ( " Optional filter logic (e.g., [gender] = 'female')" )
2026-03-30 20:19:52 +02:00
) ,
uiOutput ( ns ( " filter_feedback" ) )
2025-04-03 13:11:02 +02:00
)
2024-12-19 11:32:09 +01:00
params_ui <-
2025-01-15 16:21:38 +01:00
shiny :: tagList (
2025-09-25 10:07:19 +02:00
shiny :: tags $ h4 ( i18n $ t ( " Data import parameters" ) ) ,
2025-04-03 13:11:02 +02:00
shiny :: tags $ div (
2026-03-30 20:19:52 +02:00
####
#### All below was deactivated to deactivate filtering
####
2025-06-25 10:49:34 +02:00
style = htmltools :: css (
display = " grid" ,
gridTemplateColumns = " 1fr 50px" ,
gridColumnGap = " 10px"
2025-04-03 13:11:02 +02:00
) ,
2025-06-25 10:49:34 +02:00
shiny :: uiOutput ( outputId = ns ( " fields" ) ) ,
shiny :: tags $ div (
class = " shiny-input-container" ,
shiny :: tags $ label (
class = " control-label" ,
`for` = ns ( " dropdown_params" ) ,
" ..." ,
style = htmltools :: css ( visibility = " hidden" )
2025-04-03 13:11:02 +02:00
) ,
2025-06-25 10:49:34 +02:00
shinyWidgets :: dropMenu (
shiny :: actionButton (
inputId = ns ( " dropdown_params" ) ,
2026-04-01 23:41:23 +02:00
label = phosphoricons :: ph ( " funnel" , weight = " bold" ) ,
# label = shiny::icon("filter"),
2025-06-25 10:49:34 +02:00
width = " 50px"
) ,
filter_ui
)
)
2025-04-03 13:11:02 +02:00
) ,
2026-03-27 21:56:57 +01:00
shiny :: helpText (
i18n $ t (
" Select fields/variables to import and click the funnel to apply optional filters"
)
) ,
2025-06-25 10:49:34 +02:00
shiny :: tags $ br ( ) ,
2025-04-09 12:31:08 +02:00
shiny :: tags $ br ( ) ,
2025-03-11 13:42:57 +01:00
shiny :: uiOutput ( outputId = ns ( " data_type" ) ) ,
shiny :: uiOutput ( outputId = ns ( " fill" ) ) ,
2025-02-27 13:34:45 +01:00
shiny :: actionButton (
inputId = ns ( " data_import" ) ,
2025-09-25 10:07:19 +02:00
label = i18n $ t ( " Import" ) ,
2026-04-01 23:41:23 +02:00
icon = phosphoricons :: ph ( " download-simple" , weight = " bold" ) ,
# icon = shiny::icon("download", lib = "glyphicon"),
2025-02-27 13:34:45 +01:00
width = " 100%" ,
disabled = TRUE
2024-12-19 11:32:09 +01:00
) ,
2025-04-03 13:11:02 +02:00
shiny :: tags $ br ( ) ,
shiny :: tags $ br ( ) ,
tags $ div (
id = ns ( " retrieved-placeholder" ) ,
shinyWidgets :: alert (
id = ns ( " retrieved-result" ) ,
status = " info" ,
2026-03-27 21:56:57 +01:00
tags $ p (
phosphoricons :: ph ( " info" , weight = " bold" ) ,
" Please specify data to download, then press 'Import'."
)
2025-04-03 13:11:02 +02:00
) ,
dismissible = TRUE
2025-06-25 10:49:34 +02:00
)
2024-12-19 11:32:09 +01:00
)
2025-04-03 13:11:02 +02:00
shiny :: fluidPage (
title = title ,
server_ui ,
2025-06-25 10:49:34 +02:00
# shiny::uiOutput(ns("params_ui")),
2026-03-27 21:56:57 +01:00
shiny :: conditionalPanel ( condition = " output.connect_success == true" , params_ui , ns = ns ) ,
2025-04-03 13:11:02 +02:00
shiny :: br ( )
2024-12-19 11:32:09 +01:00
)
}
2025-02-27 13:34:45 +01:00
2024-12-19 11:32:09 +01:00
#' @rdname redcap_read_shiny_module
#'
#' @return shiny server module
#' @export
#'
2025-02-26 21:09:08 +01:00
m_redcap_readServer <- function ( id ) {
2024-12-19 11:32:09 +01:00
module <- function ( input , output , session ) {
ns <- session $ ns
2025-02-26 21:09:08 +01:00
data_rv <- shiny :: reactiveValues (
dd_status = NULL ,
data_status = NULL ,
2025-02-27 13:34:45 +01:00
uri = NULL ,
project_name = NULL ,
2025-02-26 21:09:08 +01:00
info = NULL ,
arms = NULL ,
dd_list = NULL ,
2025-03-11 13:42:57 +01:00
data = NULL ,
rep_fields = NULL ,
2026-03-30 20:19:52 +02:00
code = NULL ,
filter_valid = NULL
2025-02-26 21:09:08 +01:00
)
2024-12-19 11:32:09 +01:00
2025-02-27 13:34:45 +01:00
shiny :: observeEvent ( list ( input $ api , input $ uri ) , {
2025-03-13 12:41:50 +01:00
shiny :: req ( input $ api )
shiny :: req ( input $ uri )
2025-04-03 13:11:02 +02:00
if ( ! is.null ( input $ uri ) ) {
2026-03-27 21:56:57 +01:00
uri <- paste0 ( ifelse (
endsWith ( input $ uri , " /" ) ,
input $ uri ,
paste0 ( input $ uri , " /" )
) , " api/" )
2025-03-13 12:41:50 +01:00
} else {
uri <- input $ uri
}
2025-02-27 13:34:45 +01:00
if ( is_valid_redcap_url ( uri ) & is_valid_token ( input $ api ) ) {
data_rv $ uri <- uri
shiny :: updateActionButton ( inputId = " data_connect" , disabled = FALSE )
} else {
shiny :: updateActionButton ( inputId = " data_connect" , disabled = TRUE )
}
} )
2026-03-27 21:56:57 +01:00
tryCatch ( {
shiny :: observeEvent ( list ( input $ data_connect ) , {
shiny :: req ( input $ api )
shiny :: req ( data_rv $ uri )
2025-02-26 21:09:08 +01:00
2026-03-27 21:56:57 +01:00
parameters <- list ( redcap_uri = data_rv $ uri , token = input $ api )
2025-02-26 21:09:08 +01:00
2026-03-27 21:56:57 +01:00
# browser()
shiny :: withProgress ( {
imported <- try ( rlang :: exec ( REDCapR :: redcap_metadata_read , ! ! ! parameters ) ,
silent = TRUE )
} , message = paste ( " Connecting to" , data_rv $ uri ) )
## TODO: Simplify error messages
if ( inherits ( imported , " try-error" ) ||
NROW ( imported ) < 1 ||
ifelse ( is.list ( imported ) , ! isTRUE ( imported $ success ) , FALSE ) ) {
if ( ifelse ( is.list ( imported ) ,
! isTRUE ( imported $ success ) ,
FALSE ) ) {
mssg <- imported $ raw_text
} else {
mssg <- attr ( imported , " condition" ) $ message
}
datamods ::: insert_error ( mssg = mssg , selector = " connect" )
data_rv $ dd_status <- " error"
data_rv $ dd_list <- NULL
} else if ( isTRUE ( imported $ success ) ) {
data_rv $ dd_status <- " success"
2025-02-26 21:09:08 +01:00
2026-03-27 21:56:57 +01:00
data_rv $ info <- REDCapR :: redcap_project_info_read ( redcap_uri = data_rv $ uri , token = input $ api ) $ data
datamods ::: insert_alert (
selector = ns ( " connect" ) ,
status = " success" ,
include_data_alert (
see_data_text = i18n $ t ( " Click to see data dictionary" ) ,
dataIdName = " see_dd" ,
extra = tags $ p (
tags $ b (
phosphoricons :: ph ( " check" , weight = " bold" ) ,
i18n $ t ( " Connected to server!" )
) ,
glue :: glue (
i18n $ t (
" The {data_rv$info$project_title} project is loaded."
)
2025-02-26 21:09:08 +01:00
)
2026-03-27 21:56:57 +01:00
) ,
btn_show_data = TRUE
)
)
2025-02-26 21:09:08 +01:00
2026-03-27 21:56:57 +01:00
data_rv $ dd_list <- imported
}
} , ignoreInit = TRUE )
} , warning = function ( warn ) {
showNotification ( paste0 ( warn ) , type = " warning" )
} , error = function ( err ) {
2026-03-30 20:19:52 +02:00
showNotification ( paste0 ( err ) , type = " error" )
2026-03-27 21:56:57 +01:00
} )
2024-12-19 11:32:09 +01:00
2025-04-03 13:11:02 +02:00
output $ connect_success <- shiny :: reactive ( identical ( data_rv $ dd_status , " success" ) )
shiny :: outputOptions ( output , " connect_success" , suspendWhenHidden = FALSE )
2025-06-25 10:49:34 +02:00
2025-05-10 11:30:36 +02:00
shiny :: observeEvent ( input $ see_dd , {
show_data (
2025-02-26 21:09:08 +01:00
purrr :: pluck ( data_rv $ dd_list , " data" ) ,
2025-09-25 10:07:19 +02:00
title = i18n $ t ( " Data dictionary" ) ,
2025-02-26 21:09:08 +01:00
type = " modal" ,
show_classes = FALSE ,
2025-09-25 10:07:19 +02:00
tags $ b ( i18n $ t ( " Preview:" ) )
2025-02-27 13:34:45 +01:00
)
2024-12-19 11:32:09 +01:00
} )
2025-05-10 11:30:36 +02:00
shiny :: observeEvent ( input $ see_data , {
show_data (
# purrr::pluck(data_rv$dd_list, "data"),
data_rv $ data ,
2025-09-25 10:07:19 +02:00
title = i18n $ t ( " Imported data set" ) ,
2025-05-10 11:30:36 +02:00
type = " modal" ,
show_classes = FALSE ,
2025-09-25 10:07:19 +02:00
tags $ b ( i18n $ t ( " Preview:" ) )
2025-05-10 11:30:36 +02:00
)
} )
2024-12-19 11:32:09 +01:00
arms <- shiny :: reactive ( {
shiny :: req ( input $ api )
2025-02-27 13:34:45 +01:00
shiny :: req ( data_rv $ uri )
2024-12-19 11:32:09 +01:00
2026-03-27 21:56:57 +01:00
REDCapR :: redcap_event_read ( redcap_uri = data_rv $ uri , token = input $ api ) $ data
2024-12-19 11:32:09 +01:00
} )
output $ fields <- shiny :: renderUI ( {
2025-02-26 21:09:08 +01:00
shiny :: req ( data_rv $ dd_list )
2024-12-19 11:32:09 +01:00
shinyWidgets :: virtualSelectInput (
inputId = ns ( " fields" ) ,
2025-09-25 10:07:19 +02:00
label = i18n $ t ( " Select fields/variables to import:" ) ,
2025-02-26 21:09:08 +01:00
choices = purrr :: pluck ( data_rv $ dd_list , " data" ) | >
2024-12-19 11:32:09 +01:00
dplyr :: select ( field_name , form_name ) | >
2026-03-27 21:56:57 +01:00
( \ ( .x ) {
2025-03-11 13:42:57 +01:00
split ( .x $ field_name , REDCapCAST :: as_factor ( .x $ form_name ) )
2025-02-27 13:34:45 +01:00
} ) ( ) ,
updateOn = " change" ,
2024-12-19 11:32:09 +01:00
multiple = TRUE ,
search = TRUE ,
2025-06-25 10:49:34 +02:00
showValueAsTags = TRUE ,
width = " 100%"
2024-12-19 11:32:09 +01:00
)
} )
2025-03-11 13:42:57 +01:00
output $ data_type <- shiny :: renderUI ( {
shiny :: req ( data_rv $ info )
if ( isTRUE ( data_rv $ info $ has_repeating_instruments_or_events ) ) {
vectorSelectInput (
inputId = ns ( " data_type" ) ,
2025-09-25 10:07:19 +02:00
label = i18n $ t ( " Specify the data format" ) ,
2025-03-11 13:42:57 +01:00
choices = c (
" Wide data (One row for each subject)" = " wide" ,
" Long data for project with repeating instruments (default REDCap)" = " long"
) ,
selected = " wide" ,
2025-06-25 10:49:34 +02:00
multiple = FALSE ,
width = " 100%"
2025-03-11 13:42:57 +01:00
)
}
} )
output $ fill <- shiny :: renderUI ( {
shiny :: req ( data_rv $ info )
shiny :: req ( input $ data_type )
## Get repeated field
2026-03-27 21:56:57 +01:00
data_rv $ rep_fields <- data_rv $ dd_list $ data $ field_name [data_rv $ dd_list $ data $ form_name %in% repeated_instruments ( uri = data_rv $ uri , token = input $ api ) ]
2025-03-11 13:42:57 +01:00
2026-03-27 21:56:57 +01:00
if ( input $ data_type == " long" &&
isTRUE ( any ( input $ fields %in% data_rv $ rep_fields ) ) ) {
2025-03-11 13:42:57 +01:00
vectorSelectInput (
inputId = ns ( " fill" ) ,
2025-09-25 10:07:19 +02:00
label = i18n $ t ( " Fill missing values?" ) ,
2025-03-11 13:42:57 +01:00
choices = c (
" Yes, fill missing, non-repeated values" = " yes" ,
" No, leave the data as is" = " no"
) ,
2025-04-08 13:45:07 +02:00
selected = " no" ,
2025-06-25 10:49:34 +02:00
multiple = FALSE ,
width = " 100%"
2025-03-11 13:42:57 +01:00
)
}
} )
2025-02-27 13:34:45 +01:00
shiny :: observeEvent ( input $ fields , {
if ( is.null ( input $ fields ) | length ( input $ fields ) == 0 ) {
shiny :: updateActionButton ( inputId = " data_import" , disabled = TRUE )
} else {
shiny :: updateActionButton ( inputId = " data_import" , disabled = FALSE )
}
} )
2025-02-26 21:09:08 +01:00
2024-12-19 11:32:09 +01:00
output $ arms <- shiny :: renderUI ( {
2025-04-03 13:11:02 +02:00
if ( NROW ( arms ( ) ) > 0 ) {
vectorSelectInput (
inputId = ns ( " arms" ) ,
selected = NULL ,
label = " Filter by events/arms" ,
choices = stats :: setNames ( arms ( ) [ [3 ] ] , arms ( ) [ [1 ] ] ) ,
2025-06-25 10:49:34 +02:00
multiple = TRUE ,
width = " 100%"
2025-04-03 13:11:02 +02:00
)
}
2024-12-19 11:32:09 +01:00
} )
2026-03-30 20:19:52 +02:00
filter_validation <- reactive ( {
val <- trimws ( input $ filter )
if ( nchar ( val ) == 0 )
return ( NULL )
validate_redcap_filter ( val , purrr :: pluck ( data_rv $ dd_list , " data" ) )
} )
output $ filter_feedback <- renderUI ( {
result <- filter_validation ( )
if ( is.null ( result ) ) {
data_rv $ filter_valid <- NULL
return ( NULL )
}
if ( result $ valid ) {
data_rv $ filter_valid <- TRUE
tags $ span ( style = " color: green;" , " \u2713 Filter is valid" )
} else {
data_rv $ filter_valid <- FALSE
tags $ span ( style = " color: red;" ,
" \u2717 " ,
line_break ( result $ message , lineLength = 30 ) )
}
} )
2025-02-26 21:09:08 +01:00
shiny :: observeEvent ( input $ data_import , {
2024-12-19 11:32:09 +01:00
shiny :: req ( input $ fields )
2025-04-03 13:11:02 +02:00
# browser()
2025-02-26 21:09:08 +01:00
record_id <- purrr :: pluck ( data_rv $ dd_list , " data" ) [ [1 ] ] [1 ]
2024-12-19 11:32:09 +01:00
2026-03-30 20:19:52 +02:00
if ( ! is.null ( data_rv $ filter_valid ) ) {
if ( isTRUE ( data_rv $ filter_valid ) ) {
filter <- trimws ( input $ filter )
} else {
filter <- " "
}
} else {
filter <- " "
}
2025-02-26 21:09:08 +01:00
parameters <- list (
2025-02-27 13:34:45 +01:00
uri = data_rv $ uri ,
2024-12-19 11:32:09 +01:00
token = input $ api ,
fields = unique ( c ( record_id , input $ fields ) ) ,
events = input $ arms ,
raw_or_label = " both" ,
2026-03-30 20:19:52 +02:00
filter_logic = filter ,
# filter_logic = "",
2025-04-03 13:11:02 +02:00
split_forms = ifelse (
input $ data_type == " long" && ! is.null ( input $ data_type ) ,
" none" ,
" all"
)
2024-12-19 11:32:09 +01:00
)
2025-02-27 13:34:45 +01:00
shiny :: withProgress ( message = " Downloading REDCap data. Hold on for a moment.." , {
2026-03-30 20:19:52 +02:00
imported <- try ( {
rlang :: exec ( REDCapCAST :: read_redcap_tables , ! ! ! parameters )
# if (nrow(out)==0){
# stop("No data was exported")
# } else {
# out
# }
} , # error = function(err) {
# showNotification(i18n$t("An error was encountered exporting data. Please review data filter."), type = "error")
# },
silent = TRUE )
2025-02-27 13:34:45 +01:00
} )
2025-03-17 15:00:13 +01:00
2026-03-27 21:56:57 +01:00
# d <- REDCapCAST::apply_factor_labels(data = imported$survey, meta = data_rv$dd_list$data)
parameters_code <- parameters [c ( " uri" ,
" fields" ,
" events" ,
" raw_or_label" ,
" filter_logic" ) ]
code <- rlang :: call2 ( " easy_redcap" ,
! ! ! utils :: modifyList (
parameters_code ,
list (
data_format = ifelse (
input $ data_type == " long" && ! is.null ( input $ data_type ) ,
" long" ,
" wide"
) ,
project.name = simple_snake ( data_rv $ info $ project_title )
)
) ,
.ns = " REDCapCAST" )
2025-02-26 21:09:08 +01:00
2026-03-30 20:19:52 +02:00
if ( inherits ( imported , " try-error" ) |
NROW ( imported ) == 0 |
( length ( imported ) == 1 & ! is.list ( imported ) ) ) {
2025-02-26 21:09:08 +01:00
data_rv $ data_status <- " error"
data_rv $ data_list <- NULL
2026-03-30 20:19:52 +02:00
data_rv $ data_message <- i18n $ t ( " An empty data set was imported. Please review data filter." )
data_rv $ data <- NULL
2024-12-19 11:32:09 +01:00
} else {
2025-02-26 21:09:08 +01:00
data_rv $ data_status <- " success"
2025-09-25 10:07:19 +02:00
data_rv $ data_message <- i18n $ t ( " Requested data was retrieved!" )
2025-03-11 13:42:57 +01:00
## The data management below should be separated to allow for changing
## "wide"/"long" without re-importing data
2025-04-03 13:11:02 +02:00
if ( parameters $ split_form == " all" ) {
2025-03-11 13:42:57 +01:00
out <- imported | >
# redcap_wider()
REDCapCAST :: redcap_wider ( )
} else {
2026-03-30 20:19:52 +02:00
if ( identical ( input $ fill , " yes" ) ) {
2025-03-11 13:42:57 +01:00
## Repeated fields
## Non-repeated fields in current dataset
inc_non_rep <- names ( imported ) [ ! names ( imported ) %in% data_rv $ rep_fields ]
out <- imported | >
drop_empty_event ( ) | >
dplyr :: group_by ( ! ! dplyr :: sym ( names ( imported ) [1 ] ) ) | >
tidyr :: fill ( inc_non_rep ) | >
dplyr :: ungroup ( )
} else {
out <- imported | >
drop_empty_event ( )
}
}
2026-03-27 21:56:57 +01:00
## Ensure correct factor labels
## It is a little hacky and should be included in the read_redcap_tables, but is lost along the way
out <- REDCapCAST :: apply_factor_labels ( data = out , meta = data_rv $ dd_list $ data )
2025-04-03 13:11:02 +02:00
in_data_check <- parameters $ fields %in% names ( out ) |
2026-03-27 21:56:57 +01:00
sapply ( names ( out ) , \ ( .x ) any ( sapply (
parameters $ fields , \ ( .y ) startsWith ( .x , .y )
) ) )
2025-04-03 13:11:02 +02:00
if ( ! any ( in_data_check [ -1 ] ) ) {
data_rv $ data_status <- " warning"
2026-03-27 21:56:57 +01:00
data_rv $ data_message <- i18n $ t (
" Data retrieved, but it looks like only the ID was retrieved from the server. Please check with your REDCap administrator that you have required permissions for data access."
)
2025-04-03 13:11:02 +02:00
}
if ( ! all ( in_data_check ) ) {
data_rv $ data_status <- " warning"
2026-03-27 21:56:57 +01:00
data_rv $ data_message <- i18n $ t (
" Data retrieved, but it looks like not all requested fields were retrieved from the server. Please check with your REDCap administrator that you have required permissions for data access."
)
2025-04-03 13:11:02 +02:00
}
2025-03-17 15:00:13 +01:00
data_rv $ code <- code
2026-03-27 21:56:57 +01:00
## Level labels nare lost at this point...
2025-03-11 13:42:57 +01:00
data_rv $ data <- out | >
2025-02-26 21:09:08 +01:00
dplyr :: select ( - dplyr :: ends_with ( " _complete" ) ) | >
2025-03-11 13:42:57 +01:00
# dplyr::select(-dplyr::any_of(record_id)) |>
2025-02-26 21:09:08 +01:00
REDCapCAST :: suffix2label ( )
2026-03-27 21:56:57 +01:00
2024-12-19 11:32:09 +01:00
}
} )
2025-02-26 21:09:08 +01:00
2026-03-27 21:56:57 +01:00
shiny :: observeEvent ( data_rv $ data_status , {
if ( identical ( data_rv $ data_status , " error" ) ) {
2026-03-30 20:19:52 +02:00
## The insert error wouldn't work. Inserted through regular.
# datamods:::insert_error(mssg = data_rv$data_message,
# selector = ns("retrieved"))
datamods ::: insert_alert (
selector = ns ( " retrieved" ) ,
status = " danger" ,
tags $ p (
tags $ b (
phosphoricons :: ph ( " warning" , weight = " bold" ) ,
" Warning!"
) ,
data_rv $ data_message
)
)
2026-03-27 21:56:57 +01:00
} else if ( identical ( data_rv $ data_status , " success" ) ) {
datamods ::: insert_alert (
selector = ns ( " retrieved" ) ,
status = data_rv $ data_status ,
# tags$p(
# tags$b(phosphoricons::ph("check", weight = "bold"), "Success!"),
# data_rv$data_message
# ),
include_data_alert (
see_data_text = i18n $ t ( " Click to see the imported data" ) ,
dataIdName = " see_data" ,
extra = tags $ p ( tags $ b (
phosphoricons :: ph ( " check" , weight = " bold" ) ,
2025-04-03 13:11:02 +02:00
data_rv $ data_message
2026-03-27 21:56:57 +01:00
) ) ,
btn_show_data = TRUE
2025-04-03 13:11:02 +02:00
)
2026-03-27 21:56:57 +01:00
)
} else {
datamods ::: insert_alert (
selector = ns ( " retrieved" ) ,
status = data_rv $ data_status ,
tags $ p (
tags $ b (
phosphoricons :: ph ( " warning" , weight = " bold" ) ,
" Warning!"
) ,
data_rv $ data_message
)
)
2025-04-03 13:11:02 +02:00
}
2026-03-27 21:56:57 +01:00
} )
2025-03-11 13:42:57 +01:00
2026-03-27 21:56:57 +01:00
return (
list (
status = shiny :: reactive ( data_rv $ data_status ) ,
name = shiny :: reactive ( data_rv $ info $ project_title ) ,
info = shiny :: reactive ( data_rv $ info ) ,
code = shiny :: reactive ( data_rv $ code ) ,
data = shiny :: reactive ( data_rv $ data )
)
)
2024-12-19 11:32:09 +01:00
}
2026-03-27 21:56:57 +01:00
shiny :: moduleServer ( id = id , module = module )
2024-12-19 11:32:09 +01:00
}
2025-02-26 21:09:08 +01:00
#' @importFrom htmltools tagList tags
#' @importFrom shiny icon getDefaultReactiveDomain
2025-03-03 08:44:46 +01:00
include_data_alert <- function ( dataIdName = " see_data" ,
2025-02-26 21:09:08 +01:00
btn_show_data ,
2025-02-27 13:34:45 +01:00
see_data_text = " Click to see data" ,
2025-02-26 21:09:08 +01:00
extra = NULL ,
session = shiny :: getDefaultReactiveDomain ( ) ) {
if ( isTRUE ( btn_show_data ) ) {
2026-03-27 21:56:57 +01:00
success_message <- tagList ( extra ,
tags $ br ( ) ,
shiny :: actionLink (
inputId = session $ ns ( dataIdName ) ,
label = tagList ( phosphoricons :: ph ( " book-open-text" ) , see_data_text )
) )
2025-02-26 21:09:08 +01:00
}
return ( success_message )
}
2025-02-25 09:51:42 +01:00
# #' REDCap import teal data module
# #'
# #' @rdname redcap_read_shiny_module
# tdm_redcap_read <- teal::teal_data_module(
# ui <- function(id) {
# shiny::fluidPage(
# m_redcap_readUI(id)
# )
# },
# server = function(id) {
# m_redcap_readServer(id, output.format = "teal")
# }
# )
2024-12-19 11:32:09 +01:00
2025-03-11 13:42:57 +01:00
#' Test if url is valid format for REDCap API
2025-02-27 13:34:45 +01:00
#'
2025-03-11 13:42:57 +01:00
#' @param url url
2025-02-27 13:34:45 +01:00
#'
2025-03-11 13:42:57 +01:00
#' @returns logical
2025-02-27 13:34:45 +01:00
#' @export
#'
#' @examples
#' url <- c(
#' "www.example.com",
2025-03-11 13:42:57 +01:00
#' "redcap.your.inst/api/",
#' "https://redcap.your.inst/api/",
#' "https://your.inst/redcap/api/",
#' "https://www.your.inst/redcap/api/"
2025-02-27 13:34:45 +01:00
#' )
#' is_valid_redcap_url(url)
is_valid_redcap_url <- function ( url ) {
pattern <- " https://[^ /$.?#].[^\\s]*/api/$"
stringr :: str_detect ( url , pattern )
}
#' Validate REDCap token
#'
#' @param token token
#' @param pattern_env pattern
#'
2025-03-11 13:42:57 +01:00
#' @returns logical
2025-02-27 13:34:45 +01:00
#' @export
#'
#' @examples
#' token <- paste(sample(c(1:9, LETTERS[1:6]), 32, TRUE), collapse = "")
#' is_valid_token(token)
2026-03-27 21:56:57 +01:00
is_valid_token <- function ( token ,
pattern_env = NULL ,
nchar = 32 ) {
2025-02-27 13:34:45 +01:00
checkmate :: assert_character ( token , any.missing = TRUE , len = 1 )
if ( ! is.null ( pattern_env ) ) {
2026-03-27 21:56:57 +01:00
checkmate :: assert_character ( pattern_env , any.missing = FALSE , len = 1 )
2025-02-27 13:34:45 +01:00
pattern <- pattern_env
} else {
pattern <- glue :: glue ( " ^([0-9A-Fa-f]{<nchar>})(?:\\n)?$" ,
2026-03-27 21:56:57 +01:00
.open = " <" ,
.close = " >" )
2025-02-27 13:34:45 +01:00
}
if ( is.na ( token ) ) {
out <- FALSE
} else if ( is.null ( token ) ) {
out <- FALSE
} else if ( nchar ( token ) == 0L ) {
out <- FALSE
} else if ( ! grepl ( pattern , token , perl = TRUE ) ) {
out <- FALSE
} else {
out <- TRUE
}
out
}
2025-03-11 13:42:57 +01:00
#' Get names of repeated instruments
#'
#' @param uri REDCap database uri
#' @param token database token
#'
#' @returns vector
#' @export
#'
repeated_instruments <- function ( uri , token ) {
instruments <- REDCapR :: redcap_event_instruments ( redcap_uri = uri , token = token )
unique ( instruments $ data $ form [duplicated ( instruments $ data $ form ) ] )
}
#' Drop empty events from REDCap export
#'
#' @param data data
#' @param event "redcap_event_name", "redcap_repeat_instrument" or
#' "redcap_repeat_instance"
#'
#' @returns data.frame
#' @export
#'
drop_empty_event <- function ( data , event = " redcap_event_name" ) {
2026-03-27 21:56:57 +01:00
generics <- c (
names ( data ) [1 ] ,
" redcap_event_name" ,
" redcap_repeat_instrument" ,
" redcap_repeat_instance"
)
2025-03-11 13:42:57 +01:00
filt <- split ( data , data [ [event ] ] ) | >
2026-03-27 21:56:57 +01:00
lapply ( \ ( .x ) {
2025-03-11 13:42:57 +01:00
dplyr :: select ( .x , - tidyselect :: all_of ( generics ) ) | >
REDCapCAST :: all_na ( )
} ) | >
unlist ( )
data [data [ [event ] ] %in% names ( filt ) [ ! filt ] , ]
}
2025-02-27 13:34:45 +01:00
2026-03-30 20:19:52 +02:00
#' Validate a REDCap server-side filter string against a data dictionary
#'
#' Checks that a REDCap filter expression is syntactically correct and
#' consistent with the field types defined in the project data dictionary.
#' Plain text without field references is always rejected. Multi-clause
#' filters joined by \code{AND} or \code{OR} are supported.
#'
#' @param filter A single character string containing the filter expression,
#' e.g. \code{"[age] > 18"} or \code{"[cohabitation] = '1' AND [age] > 18"}.
#' @param dictionary A data frame representing the REDCap data dictionary in
#' API export format, as returned by e.g. \code{REDCapCAST::get_redcap_metadata()}.
#' Must contain at least the columns \code{field_name} and \code{field_type}.
#' The columns \code{text_validation_type_or_show_slider_number} and
#' \code{select_choices_or_calculations} are used when present for stricter
#' type and choice validation.
#'
#' @return A named list with two elements:
#' \describe{
#' \item{\code{valid}}{Logical. \code{TRUE} if the filter passes all checks.}
#' \item{\code{message}}{Character. \code{"Filter is valid."} on success, or
#' a newline-separated string of error messages describing every problem
#' found.}
#' }
#'
#' @details
#' Validation rules by field type:
#' \describe{
#' \item{\code{calc}}{Numeric fields. Value must be an unquoted number.
#' All comparison operators (\code{=}, \code{!=}, \code{<}, \code{>},
#' \code{<=}, \code{>=}) are accepted.}
#' \item{\code{text} with date validation}{Fields with validation type
#' \code{date_ymd}, \code{date_dmy}, \code{datetime_*}, etc. Value must be
#' a quoted date/datetime string in \code{'YYYY-MM-DD'} format. All
#' comparison operators are accepted.}
#' \item{\code{text} with time validation}{Fields with validation type
#' \code{time_hh_mm_ss} or \code{time_mm_ss}. Value must be a quoted time
#' string, e.g. \code{'14:30:00'}. All comparison operators are accepted.}
#' \item{\code{radio} / \code{dropdown}}{Categorical fields. Value must be a
#' quoted choice code (e.g. \code{'1'}) that exists in the field's choice
#' list. Only \code{=} and \code{!=} are accepted.}
#' \item{\code{text} (plain)}{Free-text fields. Value must be a quoted string.
#' Only \code{=} and \code{!=} are accepted.}
#' }
#'
#' @examples
#' \dontrun{
#' dict <- REDCapCAST::get_redcap_metadata(
#' uri = "https://redcap.example.com/api/",
#' token = Sys.getenv("REDCAP_TOKEN")
#' )
#'
#' validate_redcap_filter("[age] > 18", dict)
#' #> list(valid = TRUE, message = "Filter is valid.")
#'
#' validate_redcap_filter("only plain text", dict)
#' #> list(valid = FALSE, message = "Filter must contain at least one field ...")
#'
#' validate_redcap_filter("[cohabitation] = '1' AND [age] > 18", dict)
#' #> list(valid = TRUE, message = "Filter is valid.")
#' }
#'
#' @export
# REDCap filter validation based on data dictionary
#
# REDCap filter format: [field_name] operator value
# Example: [age] > 18
# [cohabitation] = '1'
# [inclusion] > '2020-01-01'
#
# Supported field types and their allowed operators/value formats:
# text (no validation) -> string values, = != operators only
# text (date_ymd/date_dmy) -> quoted date strings, all comparison operators
# text (time_hh_mm_ss) -> quoted time strings, all comparison operators
# text (datetime_*) -> quoted datetime strings, all comparison operators
# text (autocomplete) -> string values, = != operators only
# calc -> numeric values, all comparison operators
# radio/dropdown -> quoted numeric codes, = != operators only
validate_redcap_filter <- function ( filter , dictionary ) {
# --- Input checks ---
if ( ! is.character ( filter ) ||
length ( filter ) != 1 || nchar ( trimws ( filter ) ) == 0 ) {
return ( list ( valid = FALSE , message = " Filter must be a non-empty string." ) )
}
if ( ! grepl ( " \\[.+\\]" , filter ) ) {
return (
list ( valid = FALSE , message = " Filter must contain at least one field reference in [brackets]. Plain text is not accepted." )
)
}
# --- Column names (API export format) ---
col_field <- " field_name"
col_type <- " field_type"
col_val_type <- " text_validation_type_or_show_slider_number"
col_choices <- " select_choices_or_calculations"
missing_cols <- setdiff ( c ( col_field , col_type ) , names ( dictionary ) )
if ( length ( missing_cols ) > 0 ) {
stop ( " Dictionary is missing required columns: " ,
paste ( missing_cols , collapse = " , " ) )
}
# --- Build lookup index once for O(1) field access ---
field_idx <- setNames ( seq_len ( nrow ( dictionary ) ) , dictionary [ [col_field ] ] )
has_val_type <- col_val_type %in% names ( dictionary )
has_choices <- col_choices %in% names ( dictionary )
# --- Classify field types ---
numeric_types <- c ( " calc" )
date_validations <- c (
" date_ymd" ,
" date_dmy" ,
" datetime_ymd" ,
" datetime_dmy" ,
" datetime_seconds_ymd" ,
" datetime_seconds_dmy"
)
time_validations <- c ( " time_hh_mm_ss" , " time_mm_ss" )
categorical_types <- c ( " radio" , " dropdown" , " checkbox" )
text_types <- c ( " text" , " autocomplete" )
num_ops <- c ( " =" , " !=" , " <" , " >" , " <=" , " >=" )
cat_ops <- c ( " =" , " !=" )
text_ops <- c ( " =" , " !=" )
# --- Parse filter into clauses ---
# Split on AND/OR (REDCap uses 'and'/'or' or 'AND'/'OR')
clauses <- trimws ( strsplit ( filter , " (?i)\\s+(and|or)\\s+" , perl = TRUE ) [ [1 ] ] )
clause_pattern <- " ^\\[([^\\]]+)\\]\\s*(=|!=|<=|>=|<|>)\\s*(.+)$"
errors <- character ( 0 )
for ( clause in clauses ) {
if ( ! grepl ( clause_pattern , clause , perl = TRUE ) ) {
errors <- c (
errors ,
sprintf (
" Clause '%s' does not match expected format: [field] operator value" ,
clause
)
)
next
}
parts <- regmatches ( clause , regexec ( clause_pattern , clause , perl = TRUE ) ) [ [1 ] ]
field <- parts [2 ]
operator <- parts [3 ]
value <- trimws ( parts [4 ] )
# --- Check field exists using pre-built index ---
row_i <- field_idx [field ]
if ( is.na ( row_i ) ) {
errors <- c ( errors , sprintf ( " Unknown field: [%s]" , field ) )
next
}
field_type <- dictionary [ [col_type ] ] [row_i ]
val_type <- if ( has_val_type )
dictionary [ [col_val_type ] ] [row_i ]
else
" "
if ( is.na ( val_type ) )
val_type <- " "
# --- Determine expected value format and allowed operators ---
if ( field_type %in% numeric_types ||
grepl ( " ^integer$|^number" , val_type ) ) {
if ( ! operator %in% num_ops ) {
errors <- c (
errors ,
sprintf (
" [%s] is numeric — operator '%s' is not valid. Use one of: %s" ,
field ,
operator ,
paste ( num_ops , collapse = " , " )
)
)
}
if ( ! grepl ( " ^-?[0-9]+(\\.[0-9]+)?$" , value ) ) {
errors <- c (
errors ,
sprintf (
" [%s] is numeric — value '%s' should be an unquoted number (e.g. 18 or 3.5)" ,
field ,
value
)
)
}
} else if ( val_type %in% date_validations ) {
if ( ! operator %in% num_ops ) {
errors <- c (
errors ,
sprintf (
" [%s] is a date — operator '%s' is not valid. Use one of: %s" ,
field ,
operator ,
paste ( num_ops , collapse = " , " )
)
)
}
if ( ! grepl (
" ^'[0-9]{4}-[0-9]{2}-[0-9]{2}(\\s[0-9]{2}:[0-9]{2}(:[0-9]{2})?)?'$" ,
value
) ) {
errors <- c (
errors ,
sprintf (
" [%s] is a date — value '%s' should be a quoted date string, e.g. '2020-01-31'" ,
field ,
value
)
)
}
} else if ( val_type %in% time_validations ) {
if ( ! operator %in% num_ops ) {
errors <- c (
errors ,
sprintf (
" [%s] is a time — operator '%s' is not valid. Use one of: %s" ,
field ,
operator ,
paste ( num_ops , collapse = " , " )
)
)
}
if ( ! grepl ( " ^'[0-9]{2}:[0-9]{2}(:[0-9]{2})?'$" , value ) ) {
errors <- c (
errors ,
sprintf (
" [%s] is a time — value '%s' should be a quoted time string, e.g. '14:30:00'" ,
field ,
value
)
)
}
} else if ( field_type %in% categorical_types ) {
if ( ! operator %in% cat_ops ) {
errors <- c (
errors ,
sprintf (
" [%s] is categorical — operator '%s' is not valid. Use one of: %s" ,
field ,
operator ,
paste ( cat_ops , collapse = " , " )
)
)
}
# Validate value is a known choice code
choices_raw <- if ( has_choices )
dictionary [ [col_choices ] ] [row_i ]
else
NA
if ( ! is.na ( choices_raw ) && nchar ( trimws ( choices_raw ) ) > 0 ) {
choice_codes <- trimws ( gsub ( " ,.+?(\\||$)" , " " , gsub (
" ^\\s*" , " " , strsplit ( choices_raw , " \\|" ) [ [1 ] ]
) ) )
value_unquoted <- gsub ( " ^'|'$" , " " , value )
if ( ! value_unquoted %in% choice_codes ) {
errors <- c (
errors ,
sprintf (
" [%s] is categorical — '%s' is not a valid choice code. Valid codes: %s" ,
field ,
value_unquoted ,
paste ( choice_codes , collapse = " , " )
)
)
}
}
if ( ! grepl ( " ^'.*'$" , value ) ) {
errors <- c ( errors ,
sprintf (
" [%s] is categorical — value should be quoted, e.g. '1'" ,
field
) )
}
} else {
# Plain text field
if ( ! operator %in% text_ops ) {
errors <- c (
errors ,
sprintf (
" [%s] is a text field — operator '%s' is not valid. Use one of: %s" ,
field ,
operator ,
paste ( text_ops , collapse = " , " )
)
)
}
if ( ! grepl ( " ^'.*'$" , value ) ) {
errors <- c (
errors ,
sprintf (
" [%s] is a text field — value should be quoted, e.g. 'some text'" ,
field
)
)
}
}
}
if ( length ( errors ) > 0 ) {
return ( list (
valid = FALSE ,
message = paste ( errors , collapse = " \n" )
) )
}
list ( valid = TRUE , message = " Filter is valid." )
}
2024-12-19 11:32:09 +01:00
#' Test app for the redcap_read_shiny_module
#'
#' @rdname redcap_read_shiny_module
#'
#' @examples
#' \dontrun{
2025-02-26 21:09:08 +01:00
#' redcap_demo_app()
2024-12-19 11:32:09 +01:00
#' }
2025-02-26 21:09:08 +01:00
redcap_demo_app <- function ( ) {
2024-12-19 11:32:09 +01:00
ui <- shiny :: fluidPage (
2025-04-03 13:11:02 +02:00
m_redcap_readUI ( " data" , url = NULL ) ,
2025-03-17 15:00:13 +01:00
DT :: DTOutput ( " data" ) ,
shiny :: tags $ b ( " Code:" ) ,
shiny :: verbatimTextOutput ( outputId = " code" )
2024-12-19 11:32:09 +01:00
)
server <- function ( input , output , session ) {
2025-03-17 15:00:13 +01:00
data_val <- m_redcap_readServer ( id = " data" )
2024-12-19 11:32:09 +01:00
2026-03-27 21:56:57 +01:00
output $ data <- DT :: renderDataTable ( {
shiny :: req ( data_val $ data )
data_val $ data ( )
} , options = list ( scrollX = TRUE , pageLength = 5 ) , )
2025-03-17 15:00:13 +01:00
output $ code <- shiny :: renderPrint ( {
shiny :: req ( data_val $ code )
data_val $ code ( )
} )
2024-12-19 11:32:09 +01:00
}
shiny :: shinyApp ( ui , server )
}