diff --git a/.Rbuildignore b/.Rbuildignore index f327a3f..f8d975f 100644 --- a/.Rbuildignore +++ b/.Rbuildignore @@ -40,3 +40,4 @@ inst/testdata/create_test_data.R ^CRAN-SUBMISSION$ ^updating-mocks\.md$ ^vignettes/articles$ +^air\.toml$ diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 0000000..9236f4a --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,33 @@ +--- +name: Bug report +about: Create a report to help us improve the `REDCapExploreR` package! +title: "[BUG]" +labels: bug +assignees: '' + +--- + +# Expected Behavior +A clear and concise description of what you expected to happen. + +# Current Behavior +A clear and concise description of the bug. + +## How to Reproduce the Bug: +Numbered steps to reproduce the bug behavior. + +**Screenshots** +If applicable, add screenshots to help explain your problem. + +## Failure Logs + +If applicable, submit any failure logs or error messages here. + +### Checklist +Before submitting this issue, please check and verify below that the submission meets the below criteria: + +- [ ] The issue is atomic +- [ ] The issue description is documented +- [ ] The issue title describes the problem succinctly +- [ ] Developers are assigned to the issue +- [ ] Labels are assigned to the issue \ No newline at end of file diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md new file mode 100644 index 0000000..9a01ed1 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -0,0 +1,28 @@ +--- +name: Feature request +about: Suggest an idea for this project +title: "[FEATURE]" +labels: enhancement +assignees: '' + +--- + +# Feature Request Description +A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]; It would be great to have [...], etc. + +# Proposed Solution +A clear and concise description of what you want to happen. + +**Describe alternatives you've considered** +A clear and concise description of any alternative solutions or features you've considered. + +## Additional Context +Add any other context or screenshots about the feature request here. + +### Checklist + +- [ ] The issue is atomic +- [ ] The issue description is documented +- [ ] The issue title describes the problem succinctly +- [ ] Developers are assigned to the issue +- [ ] Labels are assigned to the issue diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 0000000..3334cdb --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,40 @@ +# Description +Provide a description of the PR + +# Proposed Changes +List changes below in bullet format: + +- Change 1 +- Change 2 +- Change 3 + +**Screenshots** +If applicable, add screenshots to help explain the proposed changes + +### Issue Addressed +**Closes #[provide issue number to link here]** + +### PR Checklist +Before submitting this PR, please check and verify below that the submission meets the below criteria: + +- [ ] New/revised functions have associated tests +- [ ] New/revised functions that update downstream outputs have associated static testing files (`.RDS`) updated under `inst/testdata/create_test_data.R` +- [ ] New/revised functions use appropriate naming conventions +- [ ] New/revised functions don't repeat code +- [ ] Code changes are less than **250** lines total +- [ ] Issues linked to the PR using [GitHub's list of keywords](https://docs.github.com/en/issues/tracking-your-work-with-issues/linking-a-pull-request-to-an-issue) +- [ ] The appropriate reviewer is assigned to the PR +- [ ] The appropriate developers are assigned to the PR +- [ ] Pre-release package version incremented using `usethis::use_version()` + +# Code Review +This section to be used by the reviewer and developers during Code Review after PR submission + +### Code Review Checklist + +- [ ] I checked that new files follow naming conventions and are in the right place +- [ ] I checked that documentation is complete, clear, and without typos +- [ ] I added/edited comments to explain "why" not "how" +- [ ] I checked that all new variable and function names follow naming conventions +- [ ] I checked that new tests have been written for key business logic and/or bugs that this PR fixes +- [ ] I checked that new tests address important edge cases diff --git a/.github/workflows/pkgdown.yaml b/.github/workflows/pkgdown.yaml index c8a4ef7..f73a1f4 100644 --- a/.github/workflows/pkgdown.yaml +++ b/.github/workflows/pkgdown.yaml @@ -46,6 +46,6 @@ jobs: if: github.event_name != 'pull_request' uses: JamesIves/github-pages-deploy-action@4.1.4 with: - clean: false + clean: true branch: gh-pages - folder: docs \ No newline at end of file + folder: docs diff --git a/.lintr b/.lintr new file mode 100644 index 0000000..966f702 --- /dev/null +++ b/.lintr @@ -0,0 +1,13 @@ +linters: linters_with_defaults( + line_length_linter = line_length_linter(140), + object_length_linter = object_length_linter(45), + object_name_linter = NULL, + object_usage_linter = NULL, + indentation_linter = NULL, + commented_code_linter = NULL, + commas_linter = NULL, + pipe_consistency_linter = pipe_consistency_linter("auto") + ) +exclusions: list( + "tests/testthat/fixtures" + ) diff --git a/DESCRIPTION b/DESCRIPTION index c558785..69869e5 100644 --- a/DESCRIPTION +++ b/DESCRIPTION @@ -19,7 +19,7 @@ LazyData: true Imports: cli, DT, - dplyr, + dplyr (>= 1.1.0), ggplot2, htmltools, purrr, @@ -28,12 +28,12 @@ Imports: stats, stringr, tibble, - tidyr, tidyselect Suggests: knitr, rmarkdown, - testthat (>= 3.0.0) + testthat (>= 3.0.0), + tidyr Config/testthat/edition: 3 Roxygen: list(markdown = TRUE) RoxygenNote: 7.3.3 diff --git a/NAMESPACE b/NAMESPACE index fa8abca..bf738b1 100644 --- a/NAMESPACE +++ b/NAMESPACE @@ -4,7 +4,7 @@ S3method(print,redcap_codebook) S3method(print,redcap_quality_report) export(build_codebook) export(build_quality_report) -export(get_record_status_data) +export(build_record_status_data) export(plot_record_status) export(pull_redcap_project) export(view_codebook) @@ -17,45 +17,35 @@ importFrom(REDCapR,redcap_instruments) importFrom(REDCapR,redcap_metadata_read) importFrom(REDCapR,redcap_project_info_read) importFrom(REDCapR,redcap_read_oneshot) -importFrom(REDCapR,sanitize_token) importFrom(cli,cli_abort) -importFrom(cli,cli_fmt) importFrom(cli,cli_progress_bar) importFrom(cli,cli_progress_done) importFrom(cli,cli_progress_update) -importFrom(cli,cli_text) -importFrom(cli,cli_vec) importFrom(cli,cli_warn) -importFrom(cli,qty) importFrom(cli,symbol) importFrom(dplyr,across) importFrom(dplyr,arrange) -importFrom(dplyr,bind_cols) importFrom(dplyr,bind_rows) importFrom(dplyr,case_when) importFrom(dplyr,count) +importFrom(dplyr,cross_join) importFrom(dplyr,distinct) importFrom(dplyr,filter) -importFrom(dplyr,first) importFrom(dplyr,group_by) -importFrom(dplyr,if_any) importFrom(dplyr,if_else) importFrom(dplyr,left_join) importFrom(dplyr,mutate) importFrom(dplyr,n) importFrom(dplyr,n_distinct) importFrom(dplyr,pull) -importFrom(dplyr,recode) importFrom(dplyr,relocate) importFrom(dplyr,rename) importFrom(dplyr,right_join) importFrom(dplyr,row_number) -importFrom(dplyr,rowwise) importFrom(dplyr,select) importFrom(dplyr,slice) importFrom(dplyr,summarise) importFrom(dplyr,transmute) -importFrom(dplyr,ungroup) importFrom(ggplot2,aes) importFrom(ggplot2,element_blank) importFrom(ggplot2,element_text) @@ -67,71 +57,27 @@ importFrom(ggplot2,scale_x_discrete) importFrom(ggplot2,scale_y_discrete) importFrom(ggplot2,theme) importFrom(ggplot2,theme_minimal) -importFrom(htmltools,HTML) importFrom(htmltools,browsable) -importFrom(htmltools,save_html) +importFrom(htmltools,htmlDependency) importFrom(htmltools,tagList) importFrom(htmltools,tags) importFrom(purrr,flatten_chr) importFrom(purrr,map) -importFrom(purrr,map2) -importFrom(purrr,map2_chr) importFrom(purrr,map_chr) -importFrom(purrr,map_int) importFrom(purrr,map_lgl) -importFrom(purrr,pluck) -importFrom(purrr,pmap) -importFrom(purrr,pmap_chr) -importFrom(purrr,reduce) importFrom(rlang,"!!!") importFrom(rlang,":=") importFrom(rlang,.data) importFrom(rlang,.env) -importFrom(rlang,abort) -importFrom(rlang,as_closure) -importFrom(rlang,as_label) -importFrom(rlang,caller_arg) -importFrom(rlang,caller_env) -importFrom(rlang,catch_cnd) -importFrom(rlang,check_installed) -importFrom(rlang,cnd_muffle) -importFrom(rlang,current_call) -importFrom(rlang,current_env) -importFrom(rlang,enexpr) -importFrom(rlang,enquo) -importFrom(rlang,env_poke) -importFrom(rlang,eval_tidy) -importFrom(rlang,expr) -importFrom(rlang,get_env) -importFrom(rlang,global_env) -importFrom(rlang,is_atomic) -importFrom(rlang,is_bare_formula) -importFrom(rlang,is_bare_list) -importFrom(rlang,is_installed) -importFrom(rlang,new_environment) -importFrom(rlang,quo_get_expr) -importFrom(rlang,quo_name) -importFrom(rlang,sym) -importFrom(rlang,syms) -importFrom(rlang,try_fetch) -importFrom(rlang,zap) importFrom(stats,IQR) importFrom(stats,quantile) importFrom(stats,setNames) importFrom(stringr,str_detect) -importFrom(stringr,str_ends) -importFrom(stringr,str_replace) -importFrom(stringr,str_replace_all) importFrom(stringr,str_squish) importFrom(stringr,str_trim) importFrom(stringr,str_trunc) importFrom(tibble,as_tibble) importFrom(tibble,tibble) -importFrom(tidyr,pivot_longer) -importFrom(tidyr,pivot_wider) importFrom(tidyselect,all_of) importFrom(tidyselect,any_of) -importFrom(tidyselect,ends_with) -importFrom(tidyselect,eval_select) importFrom(tidyselect,everything) -importFrom(tidyselect,starts_with) diff --git a/R/REDCapExploreR-package.R b/R/REDCapExploreR-package.R index 7ddd5c8..c538f86 100644 --- a/R/REDCapExploreR-package.R +++ b/R/REDCapExploreR-package.R @@ -1,33 +1,23 @@ #' @keywords internal #' @aliases REDCapExploreR-package -#' @importFrom cli cli_abort cli_fmt cli_progress_bar cli_progress_done -#' cli_progress_update cli_text cli_vec cli_warn qty symbol +#' @importFrom cli cli_abort cli_progress_bar cli_progress_done +#' cli_progress_update cli_warn symbol #' @importFrom DT datatable -#' @importFrom dplyr across arrange bind_rows case_when count distinct filter -#' group_by if_any if_else n n_distinct left_join mutate pull recode relocate -#' rename right_join row_number rowwise select slice summarise transmute ungroup -#' bind_cols first +#' @importFrom dplyr across arrange bind_rows case_when count cross_join distinct +#' filter group_by if_else left_join mutate n n_distinct pull relocate rename +#' right_join row_number select slice summarise transmute #' @importFrom ggplot2 aes element_blank element_text geom_tile ggplot labs #' scale_fill_gradientn scale_x_discrete scale_y_discrete theme theme_minimal -#' @importFrom htmltools browsable HTML save_html tagList tags -#' @importFrom purrr map map2 map_chr map_int map_lgl pluck pmap_chr pmap -#' flatten_chr map2_chr reduce +#' @importFrom htmltools browsable htmlDependency tagList tags +#' @importFrom purrr flatten_chr map map_chr map_lgl #' @importFrom REDCapR redcap_arm_export redcap_event_instruments #' redcap_instruments redcap_event_read redcap_instrument_repeating #' redcap_metadata_read redcap_project_info_read redcap_read_oneshot -#' sanitize_token -#' @importFrom rlang .data .env !!! abort as_closure caller_arg caller_env -#' catch_cnd check_installed cnd_muffle current_call current_env enexpr enquo -#' env_poke eval_tidy get_env global_env is_atomic is_bare_formula -#' is_bare_list quo_name is_installed new_environment quo_get_expr try_fetch -#' zap as_label sym syms expr := +#' @importFrom rlang .data .env !!! := #' @importFrom stats IQR quantile setNames -#' @importFrom stringr str_detect str_replace str_replace_all str_squish -#' str_trunc str_trim str_ends +#' @importFrom stringr str_detect str_squish str_trim str_trunc #' @importFrom tibble as_tibble tibble -#' @importFrom tidyr pivot_longer pivot_wider -#' @importFrom tidyselect all_of any_of ends_with eval_select everything -#' starts_with +#' @importFrom tidyselect all_of any_of everything "_PACKAGE" ## usethis namespace: start diff --git a/R/choices.R b/R/choices.R new file mode 100644 index 0000000..ad00dfe --- /dev/null +++ b/R/choices.R @@ -0,0 +1,64 @@ +#' Parse REDCap choices +#' +#' @description +#' Converts explicit and implicit choices for choice-capable fields into one row +#' per coded value. Calculated fields and other uses of the shared REDCap +#' metadata column are excluded. +#' +#' @param metadata Standardized REDCap field metadata. +#' +#' @returns A tibble with field, form, choice type, order, value, and label +#' columns. +#' +#' @noRd +get_choice_rows <- function(metadata) { + if (!"field_order" %in% names(metadata)) { + metadata$field_order <- NA_integer_ + } + + metadata <- metadata |> + mutate( + choice_definition = case_when( + tolower(.data$field_type) == "yesno" ~ "1, Yes | 0, No", + tolower(.data$field_type) == "truefalse" ~ "1, True | 0, False", + TRUE ~ as.character(.data$select_choices_or_calculations) + ) + ) |> + filter( + tolower(.data$field_type) %in% + c("checkbox", "dropdown", "radio", "yesno", "truefalse"), + !get_is_missing(.data$choice_definition) + ) + + if (nrow(metadata) == 0) { + return(tibble( + field_order = integer(), + field_name = character(), + form_name = character(), + choice_type = character(), + choice_order = integer(), + choice_value = character(), + choice_label = character() + )) + } + + bind_rows(map(seq_len(nrow(metadata)), \(index) { + choices <- strsplit( + metadata$choice_definition[[index]], + "\\|" + )[[1]] + + bind_rows(map(seq_along(choices), \(choice_index) { + parts <- strsplit(choices[[choice_index]], ",", fixed = TRUE)[[1]] + tibble( + field_order = metadata$field_order[[index]], + field_name = metadata$field_name[[index]], + form_name = metadata$form_name[[index]], + choice_type = tolower(metadata$field_type[[index]]), + choice_order = choice_index, + choice_value = str_trim(parts[[1]]), + choice_label = str_trim(paste(parts[-1], collapse = ",")) + ) + })) + })) +} diff --git a/R/codebook.R b/R/codebook.R index be566bd..451ee26 100644 --- a/R/codebook.R +++ b/R/codebook.R @@ -12,7 +12,8 @@ #' - `fields`: one row per REDCap field with display-oriented metadata, #' parsed choice summaries, validation, branching logic, event applicability, #' and repeating status when available. -#' - `choices`: one row per parsed choice option. +#' - `choices`: one row per parsed choice option, including its field choice +#' type. #' - `forms`: one row per instrument/form. #' - `events`: one row per REDCap event when the project is longitudinal. #' - `event_instruments`: one row per event/form mapping when available. @@ -54,7 +55,8 @@ get_codebook <- function(project) { forms = get_codebook_forms(project), events = project$events, event_instruments = project$event_instruments, - repeating = project$repeating_instruments, + repeating = project$repeating_instruments |> + select(-any_of("redcap_event_name")), project = get_codebook_project_summary(project) ) @@ -136,27 +138,41 @@ pull_redcap_codebook <- function(redcap_uri, token) { list( metadata = get_redcapr_data(metadata_result, "metadata"), - events = get_optional_redcapr_data(redcap_event_read, redcap_uri, token), + events = get_optional_redcapr_data( + redcap_event_read, + redcap_uri, + token, + "event metadata" + ), event_instruments = get_optional_redcapr_data( redcap_event_instruments, redcap_uri, - token + token, + "event-instrument metadata" ), instruments = get_optional_redcapr_data( redcap_instruments, redcap_uri, - token + token, + "instrument metadata" ), repeating_instruments = get_optional_redcapr_data( redcap_instrument_repeating, redcap_uri, - token + token, + "repeating-instrument metadata" + ), + arms = get_optional_redcapr_data( + redcap_arm_export, + redcap_uri, + token, + "arm metadata" ), - arms = get_optional_redcapr_data(redcap_arm_export, redcap_uri, token), project_info = get_optional_redcapr_data( redcap_project_info_read, redcap_uri, - token + token, + "project metadata" ) ) } @@ -192,7 +208,7 @@ get_codebook_project <- function(project) { project$event_instruments ), instruments = get_codebook_instruments(project$instruments), - repeating_instruments = get_codebook_repeating( + repeating_instruments = get_standard_repeating_instruments( project$repeating_instruments ), arms = get_codebook_arms(project$arms), @@ -279,38 +295,9 @@ get_codebook_fields <- function(project) { get_codebook_choices <- function(project) { metadata <- project$metadata |> - mutate(field_order = row_number()) |> - filter(!get_is_missing(.data$select_choices_or_calculations)) - - if (nrow(metadata) == 0) { - return(tibble( - field_order = integer(), - field_name = character(), - form_name = character(), - choice_order = integer(), - choice_value = character(), - choice_label = character() - )) - } + mutate(field_order = row_number()) - bind_rows(map(seq_len(nrow(metadata)), \(index) { - choices <- strsplit( - metadata$select_choices_or_calculations[[index]], - "\\|" - )[[1]] - - bind_rows(map(seq_along(choices), \(choice_index) { - parts <- strsplit(choices[[choice_index]], ",", fixed = TRUE)[[1]] - tibble( - field_order = metadata$field_order[[index]], - field_name = metadata$field_name[[index]], - form_name = metadata$form_name[[index]], - choice_order = choice_index, - choice_value = str_trim(parts[[1]]), - choice_label = str_trim(paste(parts[-1], collapse = ",")) - ) - })) - })) + get_choice_rows(metadata) } get_codebook_forms <- function(project) { @@ -487,33 +474,6 @@ get_codebook_instruments <- function(instruments) { distinct(.data$form_name, .keep_all = TRUE) } -get_codebook_repeating <- function(repeating_instruments) { - repeating_instruments <- get_optional_api_table(repeating_instruments) - if (nrow(repeating_instruments) == 0 && ncol(repeating_instruments) == 0) { - return(tibble()) - } - - names(repeating_instruments) <- get_clean_names(names(repeating_instruments)) - - if ( - !"form_name" %in% names(repeating_instruments) && - "instrument_name" %in% names(repeating_instruments) - ) { - repeating_instruments <- repeating_instruments |> - mutate(form_name = as.character(.data$instrument_name)) - } - - if ( - !"redcap_event_name" %in% names(repeating_instruments) && - "unique_event_name" %in% names(repeating_instruments) - ) { - repeating_instruments <- repeating_instruments |> - mutate(redcap_event_name = as.character(.data$unique_event_name)) - } - - repeating_instruments -} - get_codebook_arms <- function(arms) { arms <- get_optional_api_table(arms) if (nrow(arms) == 0 && ncol(arms) == 0) { @@ -574,14 +534,50 @@ get_codebook_event_summary <- function(project) { get_codebook_repeating_summary <- function(project) { repeating <- project$repeating_instruments - if (nrow(repeating) == 0 || !"form_name" %in% names(repeating)) { + if (nrow(repeating) == 0) { return(tibble(form_name = character(), repeating_status = character())) } - repeating |> - filter(!get_is_missing(.data$form_name)) |> - distinct(.data$form_name) |> - mutate(repeating_status = "Repeating instrument") + repeating_forms <- if ("form_name" %in% names(repeating)) { + repeating |> + filter(!get_is_missing(.data$form_name)) |> + transmute( + form_name = as.character(.data$form_name), + repeating_status = "Repeating instrument" + ) + } else { + tibble(form_name = character(), repeating_status = character()) + } + + repeating_events <- if ( + "redcap_event_name" %in% names(repeating) && all(c("redcap_event_name", "form_name") %in% names(project$event_instruments)) + ) { + event_rows <- if ("form_name" %in% names(repeating)) { + repeating |> filter(get_is_missing(.data$form_name)) + } else { + repeating + } + event_names <- event_rows |> + filter(!get_is_missing(.data$redcap_event_name)) |> + pull(.data$redcap_event_name) + + project$event_instruments |> + filter(.data$redcap_event_name %in% event_names) |> + transmute( + form_name = as.character(.data$form_name), + repeating_status = "Repeating event" + ) + } else { + tibble(form_name = character(), repeating_status = character()) + } + + bind_rows(repeating_forms, repeating_events) |> + distinct(.data$form_name, .data$repeating_status) |> + group_by(.data$form_name) |> + summarise( + repeating_status = paste(.data$repeating_status, collapse = "; "), + .groups = "drop" + ) } get_codebook_validation_label <- function( @@ -991,292 +987,11 @@ get_codebook_datatable <- function(data, page_length) { } get_codebook_viewer_deps <- function() { - tags$head( - tags$style(HTML( - " - .redcap-codebook-viewer { - background: #f6f8fa; - color: #24292f; - font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', - sans-serif; - line-height: 1.4; - margin: 0; - min-height: 100vh; - padding: 1.25rem; - } - .redcap-codebook-header { - background: #ffffff; - border: 1px solid #d0d7de; - border-radius: 6px; - margin-bottom: 1rem; - padding: 1rem 1.25rem; - } - .redcap-codebook-header h1 { - margin-bottom: 0.25rem; - } - .redcap-codebook-subtitle { - color: #57606a; - margin-top: 0; - } - .redcap-codebook-summary { - display: flex; - flex-wrap: wrap; - gap: 0.75rem; - margin: 1rem 0 0; - } - .redcap-codebook-summary-item { - background: #f6f8fa; - border: 1px solid #d8dee4; - border-radius: 6px; - min-width: 7rem; - padding: 0.5rem 0.75rem; - } - .redcap-codebook-summary-item dt { - color: #57606a; - font-size: 0.75rem; - font-weight: 600; - margin-bottom: 0.15rem; - text-transform: uppercase; - } - .redcap-codebook-summary-item dd { - font-size: 1.2rem; - font-weight: 650; - margin: 0; - } - .redcap-codebook-nav { - background: #f6f8fa; - border-bottom: 1px solid #d8dee4; - margin: 0 -1.25rem 1rem; - padding: 0 1.25rem 0.75rem; - position: sticky; - top: 0; - z-index: 10; - } - .redcap-codebook-nav ul { - display: flex; - flex-wrap: wrap; - gap: 0.5rem; - list-style: none; - padding-left: 0; - margin: 0; - } - .redcap-codebook-tab { - background: #ffffff; - align-items: center; - display: inline-block; - border: 1px solid #d0d7de; - border-radius: 6px; - color: #24292f; - cursor: pointer; - display: inline-flex; - gap: 0.4rem; - font: inherit; - font-size: 0.9rem; - padding: 0.4rem 0.65rem; - } - .redcap-codebook-tab.is-active { - background: #0969da; - border-color: #0969da; - color: #ffffff; - } - .redcap-codebook-tab-count { - background: #f6f8fa; - border-radius: 999px; - color: #57606a; - font-size: 0.75rem; - padding: 0.05rem 0.45rem; - } - .redcap-codebook-tab.is-active .redcap-codebook-tab-count { - background: rgba(255, 255, 255, 0.18); - color: #ffffff; - } - .redcap-codebook-section { - height: 0; - opacity: 0; - overflow: hidden; - pointer-events: none; - position: absolute; - visibility: hidden; - width: 100%; - } - .redcap-codebook-section.is-active { - height: auto; - opacity: 1; - overflow: visible; - pointer-events: auto; - position: static; - visibility: visible; - } - .redcap-codebook-section-header { - align-items: flex-start; - display: flex; - gap: 1rem; - justify-content: space-between; - margin-bottom: 0.75rem; - } - .redcap-codebook-section-header h2 { - margin: 0 0 0.3rem; - } - .redcap-codebook-section-count { - background: #eaeef2; - border-radius: 999px; - color: #57606a; - flex: 0 0 auto; - font-size: 0.8rem; - margin-top: 0.15rem; - padding: 0.15rem 0.55rem; - } - .redcap-codebook-description { - color: #57606a; - margin: 0; - max-width: 72rem; - } - .redcap-codebook-table-panel { - background: #ffffff; - border: 1px solid #d0d7de; - border-radius: 6px; - overflow-x: auto; - padding: 0.75rem; - } - .redcap-codebook-empty { - color: #57606a; - font-style: italic; - margin: 0; - } - .redcap-codebook-project-details { - display: grid; - gap: 1rem; - grid-template-columns: repeat(auto-fit, minmax(18rem, 1fr)); - } - .redcap-codebook-project-detail-group { - border: 1px solid #d8dee4; - border-radius: 6px; - overflow: hidden; - } - .redcap-codebook-project-detail-group h3 { - background: #f6f8fa; - border-bottom: 1px solid #d8dee4; - font-size: 0.95rem; - margin: 0; - padding: 0.5rem 0.75rem; - } - .redcap-codebook-project-detail-table { - border-collapse: collapse; - font-size: 0.88rem; - width: 100%; - } - .redcap-codebook-project-detail-table th, - .redcap-codebook-project-detail-table td { - border-bottom: 1px solid #eaeef2; - padding: 0.45rem 0.75rem; - text-align: left; - vertical-align: top; - } - .redcap-codebook-project-detail-table th { - color: #57606a; - font-weight: 600; - width: 42%; - } - .redcap-codebook-project-detail-table tr:last-child th, - .redcap-codebook-project-detail-table tr:last-child td { - border-bottom: 0; - } - .redcap-codebook-table { - font-size: 0.86rem; - } - .redcap-codebook-table thead th { - background: #f6f8fa; - border-bottom-color: #d0d7de; - color: #24292f; - } - .redcap-codebook-table tbody td { - vertical-align: top; - } - .redcap-codebook-table tbody tr { - cursor: pointer; - } - .redcap-codebook-table tbody tr.redcap-codebook-row-selected td { - background-color: #fff8c5 !important; - } - .redcap-codebook-table-panel .dataTables_wrapper { - color: #24292f; - } - .redcap-codebook-table-panel .dataTables_filter input, - .redcap-codebook-table-panel .dataTables_length select, - .redcap-codebook-table-panel thead input { - border: 1px solid #d0d7de; - border-radius: 4px; - padding: 0.2rem 0.35rem; - } - .redcap-codebook-table-panel .dataTables_info, - .redcap-codebook-table-panel .dataTables_paginate { - color: #57606a; - font-size: 0.85rem; - margin-top: 0.5rem; - } - " - )), - tags$script(HTML( - " - document.addEventListener('DOMContentLoaded', function() { - var tabs = document.querySelectorAll('.redcap-codebook-tab'); - var sections = document.querySelectorAll('.redcap-codebook-section'); - - function showSection(sectionId) { - tabs.forEach(function(tab) { - var active = tab.getAttribute('data-section') === sectionId; - tab.classList.toggle('is-active', active); - tab.setAttribute('aria-selected', active ? 'true' : 'false'); - }); - - sections.forEach(function(section) { - section.classList.toggle('is-active', section.id === sectionId); - }); - - if (window.HTMLWidgets && window.HTMLWidgets.staticRender) { - window.HTMLWidgets.staticRender(); - } - - if (window.jQuery && window.jQuery.fn.dataTable) { - window.jQuery('.dataTable').each(function() { - if (window.jQuery.fn.dataTable.isDataTable(this)) { - window.jQuery(this).DataTable().columns.adjust(); - } - }); - } - } - - tabs.forEach(function(tab) { - tab.addEventListener('click', function(event) { - event.preventDefault(); - showSection(tab.getAttribute('data-section')); - document.querySelector('.redcap-codebook-header') - .scrollIntoView({ behavior: 'auto', block: 'start' }); - }); - }); - - document.addEventListener('click', function(event) { - var row = event.target.closest('.redcap-codebook-table tbody tr'); - if (!row) { - return; - } - - var tbody = row.parentElement; - tbody.querySelectorAll('tr.redcap-codebook-row-selected') - .forEach(function(selectedRow) { - selectedRow.classList.remove('redcap-codebook-row-selected'); - }); - row.classList.add('redcap-codebook-row-selected'); - }); - - if (window.location.hash) { - var sectionId = window.location.hash.replace('#', ''); - if (document.getElementById(sectionId)) { - showSection(sectionId); - } - } - }); - " - )) + htmlDependency( + name = "redcap-codebook-viewer", + version = "1.0.0", + src = c(file = system.file("codebook-viewer", package = "REDCapExploreR")), + stylesheet = "codebook.css", + script = "codebook.js" ) } diff --git a/R/get_record_status_data.R b/R/get_record_status_data.R index 8eb7b0f..d01ce18 100644 --- a/R/get_record_status_data.R +++ b/R/get_record_status_data.R @@ -1,7 +1,7 @@ -#' @title Get Record Status Dashboard data +#' @title Build Record Status Dashboard data #' #' @description -#' `get_record_status_data()` retrieves REDCap records and project structure +#' `build_record_status_data()` retrieves REDCap records and project structure #' through the API, then returns a plotting-friendly tile table similar to the #' high-level REDCap Record Status Dashboard. #' @@ -43,14 +43,14 @@ #' #' @examples #' \dontrun{ -#' get_record_status_data( +#' build_record_status_data( #' redcap_uri = Sys.getenv("REDCAP_URI"), #' token = Sys.getenv("REDCAP_TOKEN") #' ) #' } #' #' @export -get_record_status_data <- function(redcap_uri, token) { +build_record_status_data <- function(redcap_uri, token) { if (missing(redcap_uri)) { cli_abort("{.arg redcap_uri} and {.arg token} are required.") } @@ -135,7 +135,7 @@ get_dashboard_grid <- function(project) { if (all(is.na(record_events$event_name))) { return( - get_dashboard_cross_join(record_events, forms) |> + cross_join(record_events, forms) |> mutate(display_form_name = .data$form_label) |> select("record_id", "event_name", "form_name", "display_form_name") ) @@ -154,25 +154,6 @@ get_dashboard_grid <- function(project) { select("record_id", "event_name", "form_name", "display_form_name") } -#' Cross join two small dashboard metadata tables -#' -#' @param x A data frame. -#' @param y A data frame. -#' -#' @returns A data frame containing every row combination from `x` and `y`. -#' -#' @noRd -get_dashboard_cross_join <- function(x, y) { - x |> - mutate(.join_key = 1L) |> - left_join( - y |> mutate(.join_key = 1L), - by = ".join_key", - relationship = "many-to-many" - ) |> - select(-".join_key") -} - #' Empty dashboard grid #' #' @returns An empty tibble with the dashboard grid columns. @@ -351,7 +332,7 @@ get_dashboard_event_form_pairs <- function(project, forms) { transmute(event_name = as.character(.data[[event_field]])) |> distinct(.data$event_name) - get_dashboard_cross_join(events, forms |> select("form_name")) + cross_join(events, forms |> select("form_name")) } #' Get event labels in observed event order @@ -480,10 +461,10 @@ get_dashboard_status <- function(project) { #' @noRd get_dashboard_completion_rows <- function(project, form_name) { repeat_form <- get_is_repeating_form(project, form_name) - repeated_rows <- get_repeating_instrument_rows(project$data) + repeated_rows <- get_repeating_instrument_rows(project) rows <- if (repeat_form) { - get_matching_repeat_rows(project$data, form_name) + get_matching_repeat_rows(project, form_name) } else { !repeated_rows } @@ -491,46 +472,6 @@ get_dashboard_completion_rows <- function(project, form_name) { rows & get_event_form_enabled_rows(project, form_name) } -#' Determine whether a form is configured or observed as repeating -#' -#' @inheritParams get_dashboard_completion_rows -#' -#' @returns `TRUE` when `form_name` is a repeating instrument. -#' -#' @noRd -get_is_repeating_form <- function(project, form_name) { - if ( - "redcap_repeat_instrument" %in% - names(project$data) && - any( - as.character(project$data$redcap_repeat_instrument) == form_name, - na.rm = TRUE - ) - ) { - return(TRUE) - } - - repeating_instruments <- get_optional_api_table(project$repeating_instruments) - if (nrow(repeating_instruments) == 0) { - return(FALSE) - } - - names(repeating_instruments) <- get_clean_names(names(repeating_instruments)) - - form_columns <- intersect( - c("form_name", "instrument_name", "redcap_repeat_instrument"), - names(repeating_instruments) - ) - if (length(form_columns) == 0) { - return(FALSE) - } - - any( - as.character(repeating_instruments[[form_columns[[1]]]]) == form_name, - na.rm = TRUE - ) -} - #' Convert completion status labels to a completion proportion #' #' @param value Character completion status values after normalization. diff --git a/R/plot_record_status.R b/R/plot_record_status.R index c10cfb6..e8b6155 100644 --- a/R/plot_record_status.R +++ b/R/plot_record_status.R @@ -2,7 +2,7 @@ #' #' @description #' `plot_record_status()` creates a ggplot heat map from data returned by -#' [get_record_status_data()]. The plot is designed to resemble the REDCap +#' [build_record_status_data()]. The plot is designed to resemble the REDCap #' Record Status Dashboard while remaining a standard ggplot object that users #' can extend with additional ggplot2 layers. #' @@ -15,7 +15,7 @@ #' `pct_complete` should range from `0` to `1`, where `1` is complete and `0` is #' incomplete or unverified. Missing values are shown with `missing_color`. #' -#' @param data A dataframe returned by [get_record_status_data()]. +#' @param data A dataframe returned by [build_record_status_data()]. #' @param record_id_field Optional string naming the record ID column. By #' default, the function infers the only column other than `form_name`, #' `event_name`, and `pct_complete`. @@ -51,7 +51,7 @@ #' plot_record_status(mock_record_status_data, mode = "compact") #' #' \dontrun{ -#' data <- get_record_status_data( +#' data <- build_record_status_data( #' redcap_uri = Sys.getenv("REDCAP_URI"), #' token = Sys.getenv("REDCAP_TOKEN") #' ) @@ -97,7 +97,7 @@ plot_record_status <- function( show_y_title = show_y_title, form_label_max = form_label_max ) - plot_data <- get_compact_form_labels(plot_data, settings$form_label_max) + x_labels <- get_record_status_labels(settings$form_label_max) x_text <- if (rotate_x_text) { element_text( @@ -116,12 +116,21 @@ plot_record_status <- function( } y_title <- if (settings$show_y_title) element_text() else element_blank() - x_scale <- if (is.null(settings$x_breaks)) { + x_scale <- if (is.null(settings$x_breaks) && is.null(x_labels)) { scale_x_discrete(position = "top", drop = FALSE) + } else if (is.null(settings$x_breaks)) { + scale_x_discrete(position = "top", labels = x_labels, drop = FALSE) + } else if (is.null(x_labels)) { + scale_x_discrete( + position = "top", + breaks = settings$x_breaks, + drop = FALSE + ) } else { scale_x_discrete( position = "top", breaks = settings$x_breaks, + labels = x_labels, drop = FALSE ) } @@ -230,22 +239,19 @@ get_record_status_breaks <- function(tick_every = NULL) { function(x) x[seq(1, length(x), by = tick_every)] } -#' Truncate form label factor levels when requested +#' Build a form label function #' -#' @param data Plot data. #' @param form_label_max Maximum form label width. #' -#' @returns Plot data with updated `form_name` factor labels. +#' @returns A function that optionally truncates form labels. #' #' @noRd -get_compact_form_labels <- function(data, form_label_max = NULL) { +get_record_status_labels <- function(form_label_max = NULL) { if (is.null(form_label_max)) { - return(data) + return(NULL) } - labels <- levels(data$form_name) - levels(data$form_name) <- str_trunc(labels, width = form_label_max) - data + \(labels) as.vector(str_trunc(labels, form_label_max)) } #' Validate a nullable positive integer argument @@ -294,6 +300,13 @@ get_record_status_plot_data <- function(data, record_id_field = NULL) { cli_abort("{.arg data} must include {.field {missing_columns}}.") } + if ( + !is.numeric(data$pct_complete) || + any(!is.na(data$pct_complete) & (data$pct_complete < 0 | data$pct_complete > 1)) + ) { + cli_abort("{.field pct_complete} must contain numbers between 0 and 1.") + } + record_id_field <- get_record_status_id_field(data, record_id_field) data <- data |> diff --git a/R/quality_report.R b/R/quality_report.R index 4f28125..c4f4b2e 100644 --- a/R/quality_report.R +++ b/R/quality_report.R @@ -44,6 +44,8 @@ #' by the REDCap API. `summaries$project$field_count` is the count of distinct #' standardized metadata field names. `summaries$fields$missing_rate` is the #' field-level fraction missing among applicable rows. +#' `summaries$records` contains one row per record; its missing-field count is +#' summed across applicable event and repeat rows. #' `summaries$forms$missing_rate` and `summaries$project$missing_rate` are #' weighted fractions missing across applicable field-row cells. Applicability #' accounts for form availability, event-form mapping, repeating instrument @@ -52,6 +54,8 @@ #' `2`/Complete. Status `0`/Incomplete is handled by operational checks and #' does not contribute to missingness, even when REDCap exports checkbox #' choices as `0`. +#' Branching expressions that use unsupported REDCap functions or smart +#' variables are treated as applicable so required values remain reviewable. #' #' @examples #' \dontrun{ @@ -140,7 +144,7 @@ get_quality_report <- function( "Normalized input" ) - project <- get_project_field_applicability(project) + project$field_applicability <- get_field_applicability(project) update_report_progress( progress_bar, progress_enabled, @@ -213,7 +217,6 @@ get_quality_report <- function( event_instruments = project$event_instruments, instruments = project$instruments, repeating_instruments = project$repeating_instruments, - instrument_structure = project$instrument_structure, choices = get_choice_metadata(project), validation = get_validation_metadata(project), branching = get_branching_metadata(project), @@ -247,6 +250,8 @@ get_quality_report <- function( #' #' @returns A list containing raw records, metadata, events, event-instrument #' mapping, instruments, and repeating instrument configuration. +#' Errors from any structural metadata endpoint are reported rather than +#' converted into empty project structure. #' #' @export pull_redcap_project <- function(redcap_uri, token) { @@ -266,21 +271,29 @@ pull_redcap_project <- function(redcap_uri, token) { list( data = get_redcapr_data(data_result, "records"), metadata = get_redcapr_data(metadata_result, "metadata"), - events = get_optional_redcapr_data(redcap_event_read, redcap_uri, token), + events = get_optional_redcapr_data( + redcap_event_read, + redcap_uri, + token, + "event metadata" + ), event_instruments = get_optional_redcapr_data( redcap_event_instruments, redcap_uri, - token + token, + "event-instrument metadata" ), instruments = get_optional_redcapr_data( redcap_instruments, redcap_uri, - token + token, + "instrument metadata" ), repeating_instruments = get_optional_redcapr_data( redcap_instrument_repeating, redcap_uri, - token + token, + "repeating-instrument metadata" ) ) } @@ -315,6 +328,8 @@ get_report_progress_steps <- function(checks) { get_validate_api_credentials <- function(redcap_uri, token) { invalid_credentials <- missing(redcap_uri) || missing(token) || + !is.character(redcap_uri) || + !is.character(token) || length(redcap_uri) != 1 || length(token) != 1 || is.na(redcap_uri) || @@ -413,10 +428,9 @@ get_api_project <- function(project) { project$event_instruments ), instruments = get_optional_api_table(project$instruments), - repeating_instruments = get_optional_api_table( + repeating_instruments = get_standard_repeating_instruments( project$repeating_instruments ), - instrument_structure = tibble(), source = "api" ) @@ -625,6 +639,9 @@ get_validate_project <- function(project) { get_validate_thresholds <- function(sparse_threshold, outlier_iqr_multiplier) { if ( !is.numeric(sparse_threshold) || + length(sparse_threshold) != 1 || + is.na(sparse_threshold) || + !is.finite(sparse_threshold) || sparse_threshold <= 0 || sparse_threshold > 1 ) { @@ -633,7 +650,13 @@ get_validate_thresholds <- function(sparse_threshold, outlier_iqr_multiplier) { ) } - if (!is.numeric(outlier_iqr_multiplier) || outlier_iqr_multiplier <= 0) { + if ( + !is.numeric(outlier_iqr_multiplier) || + length(outlier_iqr_multiplier) != 1 || + is.na(outlier_iqr_multiplier) || + !is.finite(outlier_iqr_multiplier) || + outlier_iqr_multiplier <= 0 + ) { cli_abort("{.arg outlier_iqr_multiplier} must be greater than 0.") } } @@ -692,10 +715,6 @@ get_field_summary <- function(project) { })) } -get_field_rows <- function(project, form_name) { - get_form_applicable_rows(project, form_name) -} - get_field_applicable_rows <- function(project, field) { if ( "field_applicability" %in% @@ -727,7 +746,7 @@ get_field_applicability <- function(project) { }), unique(field_metadata$form_name) ) - branching_rows <- new.env(parent = emptyenv()) + branching_rows <- new.env(parent = emptyenv()) # nolint: object_usage_linter. setNames( map(fields, \(field) { @@ -752,11 +771,6 @@ get_field_applicability <- function(project) { ) } -get_project_field_applicability <- function(project) { - project$field_applicability <- get_field_applicability(project) - project -} - get_represented_fields <- function(project) { project$metadata$field_name[ map_lgl(project$metadata$field_name, \(field) { @@ -793,8 +807,8 @@ get_field_values <- function(project, field) { } get_form_applicable_rows <- function(project, form_name) { - repeated_rows <- get_repeating_instrument_rows(project$data) - matching_repeat_rows <- get_matching_repeat_rows(project$data, form_name) + repeated_rows <- get_repeating_instrument_rows(project) + matching_repeat_rows <- get_matching_repeat_rows(project, form_name) nonrepeat_rows <- !repeated_rows matching_repeat_rows | @@ -804,8 +818,8 @@ get_form_applicable_rows <- function(project, form_name) { } get_missingness_form_applicable_rows <- function(project, form_name) { - repeated_rows <- get_repeating_instrument_rows(project$data) - matching_repeat_rows <- get_matching_repeat_rows(project$data, form_name) + repeated_rows <- get_repeating_instrument_rows(project) + matching_repeat_rows <- get_matching_repeat_rows(project, form_name) nonrepeat_rows <- !repeated_rows (matching_repeat_rows | @@ -824,23 +838,6 @@ get_form_completion_assessed_rows <- function(data, form_name) { c("Unverified", "Complete") } -get_repeating_instrument_rows <- function(data) { - if (!"redcap_repeat_instrument" %in% names(data)) { - return(rep(FALSE, nrow(data))) - } - - !get_is_missing(data$redcap_repeat_instrument) -} - -get_matching_repeat_rows <- function(data, form_name) { - if (!"redcap_repeat_instrument" %in% names(data)) { - return(rep(FALSE, nrow(data))) - } - - !get_is_missing(data$redcap_repeat_instrument) & - as.character(data$redcap_repeat_instrument) == as.character(form_name) -} - get_event_form_enabled_rows <- function(project, form_name) { current_form <- form_name event_field <- get_event_field(project$data) @@ -1080,7 +1077,11 @@ get_branching_orphans <- function(metadata) { } get_high_risk_text_findings <- function(metadata) { - pattern <- "note|comment|other|describe|free.?text|detail" + pattern <- paste0( + "(?:^|[^a-z])", + "(?:notes?|comments?|other|describe|free.?text|details?)", + "(?:[^a-z]|$)" + ) risky <- metadata |> filter( .data$field_type %in% c("text", "notes"), @@ -1108,10 +1109,12 @@ get_high_risk_text_findings <- function(metadata) { } get_outlier_findings <- function(project, outlier_iqr_multiplier) { + today <- Sys.Date() + bind_rows( get_range_findings(project), get_iqr_outlier_findings(project, outlier_iqr_multiplier), - get_future_date_findings(project) + get_future_date_findings(project, today) ) } @@ -1207,7 +1210,7 @@ get_iqr_outlier_findings <- function(project, outlier_iqr_multiplier) { })) } -get_future_date_findings <- function(project) { +get_future_date_findings <- function(project, today = Sys.Date()) { date_fields <- project$metadata |> filter( .data$field_name %in% names(project$data), @@ -1219,8 +1222,11 @@ get_future_date_findings <- function(project) { bind_rows(map(seq_len(nrow(date_fields)), \(index) { field <- date_fields$field_name[[index]] - value <- get_date_values(project$data[[field]]) - future <- !is.na(value) & value > Sys.Date() + value <- as.Date( + as.character(project$data[[field]]), + format = "%Y-%m-%d" + ) + future <- !is.na(value) & value > today if (!any(future)) { return(get_empty_findings()) @@ -1238,32 +1244,12 @@ get_future_date_findings <- function(project) { repeat_instance = get_repeat_instance_values(project$data, future), field_name = field, value = as.character(project$data[[field]][future]), - expected = paste("On or before", Sys.Date()), + expected = "On or before today", message = paste("Date field", field, "contains a future date.") ) })) } -get_date_values <- function(value) { - value <- as.character(value) - parsed <- vapply( - value, - \(x) { - if (get_is_missing(x)) { - return(NA_character_) - } - - as.character(tryCatch( - suppressWarnings(as.Date(x)), - error = function(cnd) as.Date(NA) - )) - }, - character(1) - ) - - as.Date(parsed) -} - get_operational_findings <- function(project) { completion_status <- get_completion_status(project) @@ -1302,7 +1288,7 @@ get_completion_status <- function(project) { get_completion_status_value <- function(value) { value <- as.character(value) - value_lower <- tolower(value) + value_lower <- tolower(value) # nolint: object_usage_linter. case_when( get_is_missing(value) ~ NA_character_, @@ -1324,7 +1310,7 @@ get_raw_completion_status <- function(project) { bind_rows(map(completion_fields, \(field) { form_name <- sub("_complete$", "", field) - form_rows <- get_field_rows(project, form_name) + form_rows <- get_form_applicable_rows(project, form_name) if (!any(form_rows)) { return(get_empty_completion_status()) } @@ -1356,6 +1342,7 @@ get_empty_completion_status <- function() { } get_consistency_findings <- function(project) { + choices <- get_choice_rows(project$metadata) checkbox_groups <- split( names(project$data)[str_detect(names(project$data), "___")], sub( @@ -1371,10 +1358,7 @@ get_consistency_findings <- function(project) { bind_rows(map(names(checkbox_groups), \(field) { columns <- checkbox_groups[[field]] - none_columns <- columns[str_detect( - columns, - "___(none|no|not_applicable|na)$" - )] + none_columns <- get_none_choice_columns(choices, field, columns) other_columns <- setdiff(columns, none_columns) if (length(none_columns) == 0 || length(other_columns) == 0) { @@ -1423,6 +1407,24 @@ get_consistency_findings <- function(project) { })) } +get_none_choice_columns <- function(choices, field, columns) { + none_labels <- c("none", "no", "not applicable", "n/a", "na", "none of the above") + none_values <- choices |> + filter( + .data$field_name == .env$field, + str_squish(tolower(.data$choice_label)) %in% none_labels + ) |> + pull(.data$choice_value) + + metadata_columns <- paste0(field, "___", none_values) + legacy_columns <- columns[str_detect( + columns, + "___(none|no|not_applicable|na)$" + )] + + intersect(columns, unique(c(metadata_columns, legacy_columns))) +} + get_project_summary <- function(project, findings, field_summary) { summary <- tibble( record_count = n_distinct(project$data[[project$record_id_field]]), @@ -1471,21 +1473,7 @@ get_project_event_count <- function(project) { } get_repeating_enabled <- function(project) { - if (nrow(project$repeating_instruments) > 0) { - return(TRUE) - } - - if ( - nrow(project$instrument_structure) > 0 && - any( - tolower(project$instrument_structure$structure) == "repeating", - na.rm = TRUE - ) - ) { - return(TRUE) - } - - "redcap_repeat_instance" %in% names(project$data) + nrow(project$repeating_instruments) > 0 } get_form_summary <- function(project, field_summary) { @@ -1538,7 +1526,11 @@ get_record_summary <- function(project, findings) { out <- tibble( record_id = as.character(project$data[[project$record_id_field]]), missing_field_count = rowSums(missing_matrix) - ) + ) |> + summarise( + missing_field_count = sum(.data$missing_field_count), + .by = "record_id" + ) finding_counts <- findings |> filter(!get_is_missing(.data$record_id)) |> @@ -1573,31 +1565,16 @@ get_form_metadata <- function(project) { field_count = n(), required_field_count = sum(.data$required_field), choice_field_count = sum( - .data$field_type %in% c("radio", "dropdown", "checkbox") + .data$field_type %in% + c("radio", "dropdown", "checkbox", "yesno", "truefalse") ), .groups = "drop" ) } get_choice_metadata <- function(project) { - metadata <- project$metadata |> - filter(!get_is_missing(.data$select_choices_or_calculations)) - - bind_rows(map(seq_len(nrow(metadata)), \(index) { - choices <- strsplit( - metadata$select_choices_or_calculations[[index]], - "\\|" - )[[1]] - bind_rows(map(choices, \(choice) { - parts <- strsplit(choice, ",", fixed = TRUE)[[1]] - tibble( - field_name = metadata$field_name[[index]], - form_name = metadata$form_name[[index]], - choice_value = str_trim(parts[[1]]), - choice_label = str_trim(paste(parts[-1], collapse = ",")) - ) - })) - })) + get_choice_rows(project$metadata) |> + select("field_name", "form_name", "choice_value", "choice_label") } get_validation_metadata <- function(project) { @@ -1641,13 +1618,15 @@ get_redcapr_data <- function(result, label) { cli_abort("Could not parse REDCapR {label} response.") } -get_optional_redcapr_data <- function(fun, redcap_uri, token) { +get_optional_redcapr_data <- function(fun, redcap_uri, token, label) { tryCatch( get_redcapr_data( fun(redcap_uri = redcap_uri, token = token, verbose = FALSE), "optional metadata" ), - error = function(cnd) tibble() + error = function(cnd) { + cli_abort("Could not retrieve REDCap {label}.", parent = cnd) + } ) } @@ -1722,6 +1701,8 @@ get_branching_applicable_rows <- function( return(rep(TRUE, nrow(project$data))) } + # REDCap functions and smart variables exceed this parser's supported subset. + # Fail open so unsupported logic does not hide potentially required values. out <- tryCatch( get_evaluated_branching_logic(project$data, logic, branching_environment), error = function(cnd) rep(TRUE, nrow(project$data)) @@ -1820,6 +1801,11 @@ get_is_numeric_field <- function(project, field) { value <- project$data[[field]] metadata_row <- get_metadata_row(project, field) validation <- tolower(metadata_row$text_validation_type_or_show_slider_number) + field_type <- tolower(metadata_row$field_type) + + if (field_type %in% c("checkbox", "dropdown", "radio", "truefalse", "yesno")) { + return(FALSE) + } is.numeric(value) || validation %in% c("integer", "number", "float", "number_1dp", "number_2dp") diff --git a/R/repeating.R b/R/repeating.R new file mode 100644 index 0000000..fe9e012 --- /dev/null +++ b/R/repeating.R @@ -0,0 +1,112 @@ +#' Standardize repeating instrument and event configuration +#' +#' @description +#' Normalizes the metadata returned by +#' [REDCapR::redcap_instrument_repeating()] for internal use. +#' +#' @param repeating_instruments Repeating instrument and event metadata. +#' +#' @returns A tibble containing standardized `form_name` and +#' `redcap_event_name` columns. +#' +#' @noRd +get_standard_repeating_instruments <- function(repeating_instruments) { + repeating_instruments <- get_optional_api_table(repeating_instruments) + if (nrow(repeating_instruments) == 0 && ncol(repeating_instruments) == 0) { + return(tibble( + form_name = character(), + redcap_event_name = character() + )) + } + + names(repeating_instruments) <- get_clean_names(names(repeating_instruments)) + + if (!"form_name" %in% names(repeating_instruments)) { + form_column <- intersect( + c("instrument_name", "redcap_repeat_instrument"), + names(repeating_instruments) + ) + repeating_instruments$form_name <- if (length(form_column) == 0) { + NA_character_ + } else { + as.character(repeating_instruments[[form_column[[1]]]]) + } + } + + if (!"redcap_event_name" %in% names(repeating_instruments)) { + event_column <- intersect( + c("unique_event_name", "event_name"), + names(repeating_instruments) + ) + repeating_instruments$redcap_event_name <- if (length(event_column) == 0) { + NA_character_ + } else { + as.character(repeating_instruments[[event_column[[1]]]]) + } + } + + repeating_instruments |> + mutate( + form_name = as.character(.data$form_name), + redcap_event_name = as.character(.data$redcap_event_name) + ) +} + +#' Determine whether a form is configured as a repeating instrument +#' +#' @param project A normalized API project. +#' @param form_name REDCap form name. +#' +#' @returns `TRUE` when `form_name` appears in repeating configuration. +#' +#' @noRd +get_is_repeating_form <- function(project, form_name) { + any( + !get_is_missing(project$repeating_instruments$form_name) & + project$repeating_instruments$form_name == form_name + ) +} + +#' Identify exported rows for configured repeating instruments +#' +#' @description +#' Repeating configuration comes from `redcap_instrument_repeating()`. The +#' exported repeat instrument column is used only to associate data rows with +#' that configuration. +#' +#' @inheritParams get_is_repeating_form +#' +#' @returns A logical vector with one value per API data row. +#' +#' @noRd +get_repeating_instrument_rows <- function(project) { + if ( + !any(!get_is_missing(project$repeating_instruments$form_name)) || + !"redcap_repeat_instrument" %in% names(project$data) + ) { + return(rep(FALSE, nrow(project$data))) + } + + !get_is_missing(project$data$redcap_repeat_instrument) +} + +#' Identify exported rows for one configured repeating instrument +#' +#' @inheritParams get_is_repeating_form +#' +#' @returns A logical vector with one value per API data row. +#' +#' @noRd +get_matching_repeat_rows <- function(project, form_name) { + if (!get_is_repeating_form(project, form_name)) { + return(rep(FALSE, nrow(project$data))) + } + + repeating_rows <- get_repeating_instrument_rows(project) + if (!any(repeating_rows)) { + return(repeating_rows) + } + + repeating_rows & + as.character(project$data$redcap_repeat_instrument) == form_name +} diff --git a/README.Rmd b/README.Rmd index d51e713..50215e6 100644 --- a/README.Rmd +++ b/README.Rmd @@ -34,6 +34,29 @@ You can install the development version of REDCapExploreR like so: devtools::install_github("CHOP-CGTInformatics/REDCapExploreR") ``` +## Core workflows + +Store REDCap credentials outside your scripts, then pass them to the package's +build functions: + +``` r +redcap_uri <- Sys.getenv("REDCAP_URI") +token <- Sys.getenv("REDCAP_TOKEN") + +status_data <- build_record_status_data(redcap_uri, token) +plot_record_status(status_data) + +codebook <- build_codebook(redcap_uri, token) +quality_report <- build_quality_report(redcap_uri, token) +``` + +The package includes `mock_record_status_data`, `mock_codebook`, and +`mock_quality_report` for offline exploration. See the getting-started vignette +for an overview and the [quality report +article](https://chop-cgtinformatics.github.io/REDCapExploreR/articles/quality-report.html) +for a comprehensive guide to quality report checks, output elements, and +assumptions. + ## Collaboration We invite you to give feedback and collaborate with us! If you are @@ -45,4 +68,4 @@ your database and submit a bug report by creating a GitHub Please note that this project is released with a [Contributor Code of Conduct](https://github.com/CHOP-CGTInformatics/REDCapExploreR/blob/main/CONDUCT.md). -By participating you agree to abide by its terms. \ No newline at end of file +By participating you agree to abide by its terms. diff --git a/README.md b/README.md index b492812..8d098c5 100644 --- a/README.md +++ b/README.md @@ -26,6 +26,29 @@ You can install the development version of REDCapExploreR like so: devtools::install_github("CHOP-CGTInformatics/REDCapExploreR") ``` +## Core workflows + +Store REDCap credentials outside your scripts, then pass them to the +package's build functions: + +``` r +redcap_uri <- Sys.getenv("REDCAP_URI") +token <- Sys.getenv("REDCAP_TOKEN") + +status_data <- build_record_status_data(redcap_uri, token) +plot_record_status(status_data) + +codebook <- build_codebook(redcap_uri, token) +quality_report <- build_quality_report(redcap_uri, token) +``` + +The package includes `mock_record_status_data`, `mock_codebook`, and +`mock_quality_report` for offline exploration. See the getting-started +vignette for an overview and the [quality report +article](https://chop-cgtinformatics.github.io/REDCapExploreR/articles/quality-report.html) +for a comprehensive guide to quality report checks, output elements, and +assumptions. + ## Collaboration We invite you to give feedback and collaborate with us! If you are diff --git a/air.toml b/air.toml index 730b22f..c118d11 100644 --- a/air.toml +++ b/air.toml @@ -6,6 +6,3 @@ line-ending = "auto" persistent-line-breaks = true exclude = [] default-exclude = true -skip = [] -table = [] -default-table = true \ No newline at end of file diff --git a/data-raw/mock-data.R b/data-raw/mock-data.R index 51f8d0c..9e9e402 100644 --- a/data-raw/mock-data.R +++ b/data-raw/mock-data.R @@ -208,8 +208,10 @@ mock_redcap_project <- list( ), repeating_instruments = tibble::tibble( event_name = "month_1_arm_1", - instrument_name = "adverse_events", - unique_event_name = "month_1_arm_1" + arm_num = 1L, + unique_event_name = "month_1_arm_1", + form_name = "adverse_events", + custom_form_label = NA_character_ ), project_info = tibble::tibble( project_id = 1001, diff --git a/data/mock_codebook.rda b/data/mock_codebook.rda index c724485..c462185 100644 Binary files a/data/mock_codebook.rda and b/data/mock_codebook.rda differ diff --git a/data/mock_quality_report.rda b/data/mock_quality_report.rda index 4bf8476..023d3ac 100644 Binary files a/data/mock_quality_report.rda and b/data/mock_quality_report.rda differ diff --git a/data/mock_redcap_project.rda b/data/mock_redcap_project.rda index 857f866..1e28f0f 100644 Binary files a/data/mock_redcap_project.rda and b/data/mock_redcap_project.rda differ diff --git a/inst/codebook-viewer/codebook.css b/inst/codebook-viewer/codebook.css new file mode 100644 index 0000000..0808a8c --- /dev/null +++ b/inst/codebook-viewer/codebook.css @@ -0,0 +1,216 @@ +.redcap-codebook-viewer { + background: #f6f8fa; + color: #24292f; + font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + line-height: 1.4; + margin: 0; + min-height: 100vh; + padding: 1.25rem; +} + +.redcap-codebook-header { + background: #fff; + border: 1px solid #d0d7de; + border-radius: 6px; + margin-bottom: 1rem; + padding: 1rem 1.25rem; +} + +.redcap-codebook-header h1 { margin-bottom: 0.25rem; } +.redcap-codebook-subtitle { color: #57606a; margin-top: 0; } + +.redcap-codebook-summary { + display: flex; + flex-wrap: wrap; + gap: 0.75rem; + margin: 1rem 0 0; +} + +.redcap-codebook-summary-item { + background: #f6f8fa; + border: 1px solid #d8dee4; + border-radius: 6px; + min-width: 7rem; + padding: 0.5rem 0.75rem; +} + +.redcap-codebook-summary-item dt { + color: #57606a; + font-size: 0.75rem; + font-weight: 600; + margin-bottom: 0.15rem; + text-transform: uppercase; +} + +.redcap-codebook-summary-item dd { + font-size: 1.2rem; + font-weight: 650; + margin: 0; +} + +.redcap-codebook-nav { + background: #f6f8fa; + border-bottom: 1px solid #d8dee4; + margin: 0 -1.25rem 1rem; + padding: 0 1.25rem 0.75rem; + position: sticky; + top: 0; + z-index: 10; +} + +.redcap-codebook-nav ul { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; + list-style: none; + margin: 0; + padding-left: 0; +} + +.redcap-codebook-tab { + align-items: center; + background: #fff; + border: 1px solid #d0d7de; + border-radius: 6px; + color: #24292f; + cursor: pointer; + display: inline-flex; + font: inherit; + font-size: 0.9rem; + gap: 0.4rem; + padding: 0.4rem 0.65rem; +} + +.redcap-codebook-tab.is-active { + background: #0969da; + border-color: #0969da; + color: #fff; +} + +.redcap-codebook-tab-count, +.redcap-codebook-section-count { + background: #eaeef2; + border-radius: 999px; + color: #57606a; + font-size: 0.8rem; + padding: 0.1rem 0.5rem; +} + +.redcap-codebook-tab.is-active .redcap-codebook-tab-count { + background: rgb(255 255 255 / 18%); + color: #fff; +} + +.redcap-codebook-section { + height: 0; + opacity: 0; + overflow: hidden; + pointer-events: none; + position: absolute; + visibility: hidden; + width: 100%; +} + +.redcap-codebook-section.is-active { + height: auto; + opacity: 1; + overflow: visible; + pointer-events: auto; + position: static; + visibility: visible; +} + +.redcap-codebook-section-header { + align-items: flex-start; + display: flex; + gap: 1rem; + justify-content: space-between; + margin-bottom: 0.75rem; +} + +.redcap-codebook-section-header h2 { margin: 0 0 0.3rem; } +.redcap-codebook-description { color: #57606a; margin: 0; max-width: 72rem; } + +.redcap-codebook-table-panel { + background: #fff; + border: 1px solid #d0d7de; + border-radius: 6px; + overflow-x: auto; + padding: 0.75rem; +} + +.redcap-codebook-empty { color: #57606a; font-style: italic; margin: 0; } + +.redcap-codebook-project-details { + display: grid; + gap: 1rem; + grid-template-columns: repeat(auto-fit, minmax(18rem, 1fr)); +} + +.redcap-codebook-project-detail-group { + border: 1px solid #d8dee4; + border-radius: 6px; + overflow: hidden; +} + +.redcap-codebook-project-detail-group h3 { + background: #f6f8fa; + border-bottom: 1px solid #d8dee4; + font-size: 0.95rem; + margin: 0; + padding: 0.5rem 0.75rem; +} + +.redcap-codebook-project-detail-table { + border-collapse: collapse; + font-size: 0.88rem; + width: 100%; +} + +.redcap-codebook-project-detail-table th, +.redcap-codebook-project-detail-table td { + border-bottom: 1px solid #eaeef2; + padding: 0.45rem 0.75rem; + text-align: left; + vertical-align: top; +} + +.redcap-codebook-project-detail-table th { + color: #57606a; + font-weight: 600; + width: 42%; +} + +.redcap-codebook-project-detail-table tr:last-child th, +.redcap-codebook-project-detail-table tr:last-child td { border-bottom: 0; } +.redcap-codebook-table { font-size: 0.86rem; } + +.redcap-codebook-table thead th { + background: #f6f8fa; + border-bottom-color: #d0d7de; + color: #24292f; +} + +.redcap-codebook-table tbody td { vertical-align: top; } +.redcap-codebook-table tbody tr { cursor: pointer; } + +.redcap-codebook-table tbody tr.redcap-codebook-row-selected td { + background-color: #fff8c5 !important; +} + +.redcap-codebook-table-panel .dataTables_wrapper { color: #24292f; } + +.redcap-codebook-table-panel .dataTables_filter input, +.redcap-codebook-table-panel .dataTables_length select, +.redcap-codebook-table-panel thead input { + border: 1px solid #d0d7de; + border-radius: 4px; + padding: 0.2rem 0.35rem; +} + +.redcap-codebook-table-panel .dataTables_info, +.redcap-codebook-table-panel .dataTables_paginate { + color: #57606a; + font-size: 0.85rem; + margin-top: 0.5rem; +} diff --git a/inst/codebook-viewer/codebook.js b/inst/codebook-viewer/codebook.js new file mode 100644 index 0000000..a0a5245 --- /dev/null +++ b/inst/codebook-viewer/codebook.js @@ -0,0 +1,55 @@ +document.addEventListener("DOMContentLoaded", function () { + const tabs = document.querySelectorAll(".redcap-codebook-tab"); + const sections = document.querySelectorAll(".redcap-codebook-section"); + + function showSection(sectionId) { + tabs.forEach(function (tab) { + const active = tab.getAttribute("data-section") === sectionId; + tab.classList.toggle("is-active", active); + tab.setAttribute("aria-selected", active ? "true" : "false"); + }); + + sections.forEach(function (section) { + section.classList.toggle("is-active", section.id === sectionId); + }); + + if (window.HTMLWidgets && window.HTMLWidgets.staticRender) { + window.HTMLWidgets.staticRender(); + } + + if (window.jQuery && window.jQuery.fn.dataTable) { + window.jQuery(".dataTable").each(function () { + if (window.jQuery.fn.dataTable.isDataTable(this)) { + window.jQuery(this).DataTable().columns.adjust(); + } + }); + } + } + + tabs.forEach(function (tab) { + tab.addEventListener("click", function (event) { + event.preventDefault(); + showSection(tab.getAttribute("data-section")); + document + .querySelector(".redcap-codebook-header") + .scrollIntoView({ behavior: "auto", block: "start" }); + }); + }); + + document.addEventListener("click", function (event) { + const row = event.target.closest(".redcap-codebook-table tbody tr"); + if (!row) return; + + row.parentElement + .querySelectorAll("tr.redcap-codebook-row-selected") + .forEach(function (selectedRow) { + selectedRow.classList.remove("redcap-codebook-row-selected"); + }); + row.classList.add("redcap-codebook-row-selected"); + }); + + if (window.location.hash) { + const sectionId = window.location.hash.replace("#", ""); + if (document.getElementById(sectionId)) showSection(sectionId); + } +}); diff --git a/man/REDCapExploreR-package.Rd b/man/REDCapExploreR-package.Rd index 0acc101..3a88b19 100644 --- a/man/REDCapExploreR-package.Rd +++ b/man/REDCapExploreR-package.Rd @@ -6,6 +6,8 @@ \alias{REDCapExploreR-package} \title{REDCapExploreR: Exploratory Data Quality Tools for REDCap Projects} \description{ +\if{html}{\figure{logo.png}{options: style='float: right' alt='logo' width='120'}} + Provides tools to explore REDCap projects, summarize project structure, and flag common data quality issues for data managers. } \seealso{ @@ -16,5 +18,9 @@ Useful links: \item Report bugs at \url{https://github.com/CHOP-CGTInformatics/REDCapExploreR/issues} } +} +\author{ +\strong{Maintainer}: Richard Hanna \email{hannar1@chop.edu} (\href{https://orcid.org/0009-0005-6496-8154}{ORCID}) + } \keyword{internal} diff --git a/man/build_codebook.Rd b/man/build_codebook.Rd index e8c5309..eab21cb 100644 --- a/man/build_codebook.Rd +++ b/man/build_codebook.Rd @@ -17,7 +17,8 @@ A \code{redcap_codebook} list with these elements: \item \code{fields}: one row per REDCap field with display-oriented metadata, parsed choice summaries, validation, branching logic, event applicability, and repeating status when available. -\item \code{choices}: one row per parsed choice option. +\item \code{choices}: one row per parsed choice option, including its field choice +type. \item \code{forms}: one row per instrument/form. \item \code{events}: one row per REDCap event when the project is longitudinal. \item \code{event_instruments}: one row per event/form mapping when available. diff --git a/man/build_quality_report.Rd b/man/build_quality_report.Rd index a463448..cbc8c56 100644 --- a/man/build_quality_report.Rd +++ b/man/build_quality_report.Rd @@ -38,6 +38,8 @@ instance context when available from the REDCap export. by the REDCap API. \code{summaries$project$field_count} is the count of distinct standardized metadata field names. \code{summaries$fields$missing_rate} is the field-level fraction missing among applicable rows. +\code{summaries$records} contains one row per record; its missing-field count is +summed across applicable event and repeat rows. \code{summaries$forms$missing_rate} and \code{summaries$project$missing_rate} are weighted fractions missing across applicable field-row cells. Applicability accounts for form availability, event-form mapping, repeating instrument @@ -46,6 +48,8 @@ REDCap form status column \verb{_complete} is \code{1}/Unverified or \code{2}/Complete. Status \code{0}/Incomplete is handled by operational checks and does not contribute to missingness, even when REDCap exports checkbox choices as \code{0}. +Branching expressions that use unsupported REDCap functions or smart +variables are treated as applicable so required values remain reviewable. } \description{ \code{build_quality_report()} pulls a REDCap project through the API, applies diff --git a/man/get_record_status_data.Rd b/man/build_record_status_data.Rd similarity index 89% rename from man/get_record_status_data.Rd rename to man/build_record_status_data.Rd index 2b67e93..968b795 100644 --- a/man/get_record_status_data.Rd +++ b/man/build_record_status_data.Rd @@ -1,10 +1,10 @@ % Generated by roxygen2: do not edit by hand % Please edit documentation in R/get_record_status_data.R -\name{get_record_status_data} -\alias{get_record_status_data} -\title{Get Record Status Dashboard data} +\name{build_record_status_data} +\alias{build_record_status_data} +\title{Build Record Status Dashboard data} \usage{ -get_record_status_data(redcap_uri, token) +build_record_status_data(redcap_uri, token) } \arguments{ \item{redcap_uri}{REDCap API URI.} @@ -18,7 +18,7 @@ Longitudinal projects also include \code{event_name}, the REDCap unique event name. } \description{ -\code{get_record_status_data()} retrieves REDCap records and project structure +\code{build_record_status_data()} retrieves REDCap records and project structure through the API, then returns a plotting-friendly tile table similar to the high-level REDCap Record Status Dashboard. } @@ -52,7 +52,7 @@ labels are available. } \examples{ \dontrun{ -get_record_status_data( +build_record_status_data( redcap_uri = Sys.getenv("REDCAP_URI"), token = Sys.getenv("REDCAP_TOKEN") ) diff --git a/man/plot_record_status.Rd b/man/plot_record_status.Rd index 17e8bfb..0dbcb5d 100644 --- a/man/plot_record_status.Rd +++ b/man/plot_record_status.Rd @@ -27,7 +27,7 @@ plot_record_status( ) } \arguments{ -\item{data}{A dataframe returned by \code{\link[=get_record_status_data]{get_record_status_data()}}.} +\item{data}{A dataframe returned by \code{\link[=build_record_status_data]{build_record_status_data()}}.} \item{record_id_field}{Optional string naming the record ID column. By default, the function infers the only column other than \code{form_name}, @@ -76,7 +76,7 @@ A ggplot object. } \description{ \code{plot_record_status()} creates a ggplot heat map from data returned by -\code{\link[=get_record_status_data]{get_record_status_data()}}. The plot is designed to resemble the REDCap +\code{\link[=build_record_status_data]{build_record_status_data()}}. The plot is designed to resemble the REDCap Record Status Dashboard while remaining a standard ggplot object that users can extend with additional ggplot2 layers. } @@ -95,7 +95,7 @@ plot_record_status(mock_record_status_data) plot_record_status(mock_record_status_data, mode = "compact") \dontrun{ -data <- get_record_status_data( +data <- build_record_status_data( redcap_uri = Sys.getenv("REDCAP_URI"), token = Sys.getenv("REDCAP_TOKEN") ) diff --git a/man/pull_redcap_project.Rd b/man/pull_redcap_project.Rd index a515c36..3b8246b 100644 --- a/man/pull_redcap_project.Rd +++ b/man/pull_redcap_project.Rd @@ -14,6 +14,8 @@ pull_redcap_project(redcap_uri, token) \value{ A list containing raw records, metadata, events, event-instrument mapping, instruments, and repeating instrument configuration. +Errors from any structural metadata endpoint are reported rather than +converted into empty project structure. } \description{ Retrieves records and project metadata from the REDCap API using REDCapR. diff --git a/pkgdown/_pkgdown.yml b/pkgdown/_pkgdown.yml index 5277af2..ecfb00f 100644 --- a/pkgdown/_pkgdown.yml +++ b/pkgdown/_pkgdown.yml @@ -13,7 +13,7 @@ template: navbar: bg: primary structure: - left: [intro, reference] + left: [intro, reference, articles] right: [search, github] left: - icon: fa-home @@ -23,6 +23,11 @@ navbar: href: articles/REDCapExploreR.html - text: Reference href: reference/index.html + - text: Articles + menu: + - text: "Understanding Quality Report Outputs" + desc: "Navigate findings, summaries, metadata, and assumptions" + href: articles/quality-report.html search: exclude: ['news/index.html', 'AGENTS.html'] @@ -48,7 +53,7 @@ reference: desc: > Recreate and plot REDCap Record Status Dashboard data. contents: - - get_record_status_data + - build_record_status_data - plot_record_status - title: "Data" desc: > diff --git a/tests/testthat/_snaps/codebook/codebook.css b/tests/testthat/_snaps/codebook/codebook.css new file mode 100644 index 0000000..0808a8c --- /dev/null +++ b/tests/testthat/_snaps/codebook/codebook.css @@ -0,0 +1,216 @@ +.redcap-codebook-viewer { + background: #f6f8fa; + color: #24292f; + font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + line-height: 1.4; + margin: 0; + min-height: 100vh; + padding: 1.25rem; +} + +.redcap-codebook-header { + background: #fff; + border: 1px solid #d0d7de; + border-radius: 6px; + margin-bottom: 1rem; + padding: 1rem 1.25rem; +} + +.redcap-codebook-header h1 { margin-bottom: 0.25rem; } +.redcap-codebook-subtitle { color: #57606a; margin-top: 0; } + +.redcap-codebook-summary { + display: flex; + flex-wrap: wrap; + gap: 0.75rem; + margin: 1rem 0 0; +} + +.redcap-codebook-summary-item { + background: #f6f8fa; + border: 1px solid #d8dee4; + border-radius: 6px; + min-width: 7rem; + padding: 0.5rem 0.75rem; +} + +.redcap-codebook-summary-item dt { + color: #57606a; + font-size: 0.75rem; + font-weight: 600; + margin-bottom: 0.15rem; + text-transform: uppercase; +} + +.redcap-codebook-summary-item dd { + font-size: 1.2rem; + font-weight: 650; + margin: 0; +} + +.redcap-codebook-nav { + background: #f6f8fa; + border-bottom: 1px solid #d8dee4; + margin: 0 -1.25rem 1rem; + padding: 0 1.25rem 0.75rem; + position: sticky; + top: 0; + z-index: 10; +} + +.redcap-codebook-nav ul { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; + list-style: none; + margin: 0; + padding-left: 0; +} + +.redcap-codebook-tab { + align-items: center; + background: #fff; + border: 1px solid #d0d7de; + border-radius: 6px; + color: #24292f; + cursor: pointer; + display: inline-flex; + font: inherit; + font-size: 0.9rem; + gap: 0.4rem; + padding: 0.4rem 0.65rem; +} + +.redcap-codebook-tab.is-active { + background: #0969da; + border-color: #0969da; + color: #fff; +} + +.redcap-codebook-tab-count, +.redcap-codebook-section-count { + background: #eaeef2; + border-radius: 999px; + color: #57606a; + font-size: 0.8rem; + padding: 0.1rem 0.5rem; +} + +.redcap-codebook-tab.is-active .redcap-codebook-tab-count { + background: rgb(255 255 255 / 18%); + color: #fff; +} + +.redcap-codebook-section { + height: 0; + opacity: 0; + overflow: hidden; + pointer-events: none; + position: absolute; + visibility: hidden; + width: 100%; +} + +.redcap-codebook-section.is-active { + height: auto; + opacity: 1; + overflow: visible; + pointer-events: auto; + position: static; + visibility: visible; +} + +.redcap-codebook-section-header { + align-items: flex-start; + display: flex; + gap: 1rem; + justify-content: space-between; + margin-bottom: 0.75rem; +} + +.redcap-codebook-section-header h2 { margin: 0 0 0.3rem; } +.redcap-codebook-description { color: #57606a; margin: 0; max-width: 72rem; } + +.redcap-codebook-table-panel { + background: #fff; + border: 1px solid #d0d7de; + border-radius: 6px; + overflow-x: auto; + padding: 0.75rem; +} + +.redcap-codebook-empty { color: #57606a; font-style: italic; margin: 0; } + +.redcap-codebook-project-details { + display: grid; + gap: 1rem; + grid-template-columns: repeat(auto-fit, minmax(18rem, 1fr)); +} + +.redcap-codebook-project-detail-group { + border: 1px solid #d8dee4; + border-radius: 6px; + overflow: hidden; +} + +.redcap-codebook-project-detail-group h3 { + background: #f6f8fa; + border-bottom: 1px solid #d8dee4; + font-size: 0.95rem; + margin: 0; + padding: 0.5rem 0.75rem; +} + +.redcap-codebook-project-detail-table { + border-collapse: collapse; + font-size: 0.88rem; + width: 100%; +} + +.redcap-codebook-project-detail-table th, +.redcap-codebook-project-detail-table td { + border-bottom: 1px solid #eaeef2; + padding: 0.45rem 0.75rem; + text-align: left; + vertical-align: top; +} + +.redcap-codebook-project-detail-table th { + color: #57606a; + font-weight: 600; + width: 42%; +} + +.redcap-codebook-project-detail-table tr:last-child th, +.redcap-codebook-project-detail-table tr:last-child td { border-bottom: 0; } +.redcap-codebook-table { font-size: 0.86rem; } + +.redcap-codebook-table thead th { + background: #f6f8fa; + border-bottom-color: #d0d7de; + color: #24292f; +} + +.redcap-codebook-table tbody td { vertical-align: top; } +.redcap-codebook-table tbody tr { cursor: pointer; } + +.redcap-codebook-table tbody tr.redcap-codebook-row-selected td { + background-color: #fff8c5 !important; +} + +.redcap-codebook-table-panel .dataTables_wrapper { color: #24292f; } + +.redcap-codebook-table-panel .dataTables_filter input, +.redcap-codebook-table-panel .dataTables_length select, +.redcap-codebook-table-panel thead input { + border: 1px solid #d0d7de; + border-radius: 4px; + padding: 0.2rem 0.35rem; +} + +.redcap-codebook-table-panel .dataTables_info, +.redcap-codebook-table-panel .dataTables_paginate { + color: #57606a; + font-size: 0.85rem; + margin-top: 0.5rem; +} diff --git a/tests/testthat/_snaps/codebook/codebook.js b/tests/testthat/_snaps/codebook/codebook.js new file mode 100644 index 0000000..a0a5245 --- /dev/null +++ b/tests/testthat/_snaps/codebook/codebook.js @@ -0,0 +1,55 @@ +document.addEventListener("DOMContentLoaded", function () { + const tabs = document.querySelectorAll(".redcap-codebook-tab"); + const sections = document.querySelectorAll(".redcap-codebook-section"); + + function showSection(sectionId) { + tabs.forEach(function (tab) { + const active = tab.getAttribute("data-section") === sectionId; + tab.classList.toggle("is-active", active); + tab.setAttribute("aria-selected", active ? "true" : "false"); + }); + + sections.forEach(function (section) { + section.classList.toggle("is-active", section.id === sectionId); + }); + + if (window.HTMLWidgets && window.HTMLWidgets.staticRender) { + window.HTMLWidgets.staticRender(); + } + + if (window.jQuery && window.jQuery.fn.dataTable) { + window.jQuery(".dataTable").each(function () { + if (window.jQuery.fn.dataTable.isDataTable(this)) { + window.jQuery(this).DataTable().columns.adjust(); + } + }); + } + } + + tabs.forEach(function (tab) { + tab.addEventListener("click", function (event) { + event.preventDefault(); + showSection(tab.getAttribute("data-section")); + document + .querySelector(".redcap-codebook-header") + .scrollIntoView({ behavior: "auto", block: "start" }); + }); + }); + + document.addEventListener("click", function (event) { + const row = event.target.closest(".redcap-codebook-table tbody tr"); + if (!row) return; + + row.parentElement + .querySelectorAll("tr.redcap-codebook-row-selected") + .forEach(function (selectedRow) { + selectedRow.classList.remove("redcap-codebook-row-selected"); + }); + row.classList.add("redcap-codebook-row-selected"); + }); + + if (window.location.hash) { + const sectionId = window.location.hash.replace("#", ""); + if (document.getElementById(sectionId)) showSection(sectionId); + } +}); diff --git a/tests/testthat/test-codebook.R b/tests/testthat/test-codebook.R index 765535c..1057422 100644 --- a/tests/testthat/test-codebook.R +++ b/tests/testthat/test-codebook.R @@ -129,8 +129,10 @@ test_that("build_codebook returns structured codebook tables", { ) repeating <- tibble::tibble( event_name = "Follow-up", - instrument_name = "visit", - unique_event_name = "followup_arm_1" + arm_num = 1L, + unique_event_name = "followup_arm_1", + form_name = "visit", + custom_form_label = NA_character_ ) project_info <- tibble::tibble( project_id = 42, @@ -164,6 +166,7 @@ test_that("build_codebook returns structured codebook tables", { ) expect_equal(nrow(codebook$fields), 4) expect_equal(nrow(codebook$choices), 5) + expect_identical(codebook$repeating, repeating) expect_equal(codebook$project$project_title, "Example Codebook") expect_equal(codebook$project$project_id, 42) expect_equal(codebook$project$project_language, "English") @@ -222,6 +225,92 @@ test_that("build_codebook parses choices and field display metadata", { ) }) +test_that("build_codebook includes choice types and implicit binary choices", { + metadata <- tibble::tibble( + field_name = c( + "record_id", + "consented", + "eligible", + "priority", + "disposition", + "symptoms" + ), + form_name = "main", + field_type = c( + "text", + "yesno", + "truefalse", + "radio", + "dropdown", + "checkbox" + ), + field_label = c( + "Record ID", + "Consented", + "Eligible", + "Priority", + "Disposition", + "Symptoms" + ), + select_choices_or_calculations = c( + NA, + NA, + NA, + "1, Routine | 2, Urgent", + "1, Open | 2, Closed", + "fever, Fever | rash, Rash" + ) + ) + + choices <- build_mock_codebook(metadata)$choices + + expect_equal( + choices |> + dplyr::distinct(.data$field_name, .data$choice_type), + tibble::tibble( + field_name = c( + "consented", + "eligible", + "priority", + "disposition", + "symptoms" + ), + choice_type = c("yesno", "truefalse", "radio", "dropdown", "checkbox") + ) + ) + + binary_choices <- choices |> + dplyr::filter(.data$choice_type %in% c("yesno", "truefalse")) |> + dplyr::select("field_name", "choice_value", "choice_label") + + expect_equal( + binary_choices, + tibble::tibble( + field_name = c("consented", "consented", "eligible", "eligible"), + choice_value = c("1", "0", "1", "0"), + choice_label = c("Yes", "No", "True", "False") + ) + ) +}) + +test_that("build_codebook excludes calculations from choices", { + metadata <- tibble::tibble( + field_name = c("record_id", "bmi"), + form_name = "main", + field_type = c("text", "calc"), + field_label = c("Record ID", "BMI"), + select_choices_or_calculations = c( + NA, + "round([weight]/([height]^2), 1)" + ) + ) + + codebook <- build_mock_codebook(metadata) + + expect_equal(nrow(codebook$choices), 0) + expect_equal(codebook$fields$choice_count, c(0L, 0L)) +}) + test_that("build_codebook summarizes forms, events, and repeating structure", { metadata <- tibble::tibble( field_name = c("record_id", "baseline_value", "visit_value"), @@ -278,6 +367,37 @@ test_that("build_codebook summarizes forms, events, and repeating structure", { ) }) +test_that("build_codebook identifies forms in repeating events", { + metadata <- tibble::tibble( + field_name = c("record_id", "visit_date"), + form_name = "visit", + field_type = "text", + field_label = c("Record ID", "Visit Date") + ) + events <- tibble::tibble( + event_name = "Visit", + unique_event_name = "visit_arm_1", + arm_num = 1L + ) + event_instruments <- tibble::tibble( + unique_event_name = "visit_arm_1", + form = "visit" + ) + repeating <- tibble::tibble( + unique_event_name = "visit_arm_1", + form_name = NA_character_ + ) + + codebook <- build_mock_codebook( + metadata = metadata, + events = events, + event_instruments = event_instruments, + repeating_instruments = repeating + ) + + expect_equal(unique(codebook$fields$repeating_status), "Repeating event") +}) + test_that("print.redcap_codebook reports concise project counts", { metadata <- tibble::tibble( field_name = "record_id", @@ -370,16 +490,24 @@ test_that("view_codebook returns a browsable HTML object", { expect_false(grepl("Back to top", as.character(viewer), fixed = TRUE)) expect_match(as.character(viewer), "status") expect_match(as.character(viewer), "Open") - viewer_head <- htmltools::renderTags(viewer)$head - expect_match(viewer_head, "HTMLWidgets.staticRender") - expect_match(viewer_head, "columns.adjust") - expect_match(viewer_head, "scrollIntoView") - expect_match(viewer_head, "position: sticky") - expect_match(viewer_head, "redcap-codebook-summary-item") - expect_match(viewer_head, "redcap-codebook-table thead th") - expect_match(viewer_head, "redcap-codebook-project-detail-table") - expect_match(viewer_head, "redcap-codebook-row-selected") - expect_false(grepl("window.location.hash =", viewer_head, fixed = TRUE)) + dependencies <- htmltools::renderTags(viewer)$dependencies + dependency_names <- vapply(dependencies, `[[`, character(1), "name") + expect_true("redcap-codebook-viewer" %in% dependency_names) +}) + +test_that("codebook viewer assets match snapshots", { + asset_dir <- system.file("codebook-viewer", package = "REDCapExploreR") + + expect_snapshot_file( + file.path(asset_dir, "codebook.css"), + name = "codebook.css", + cran = TRUE + ) + expect_snapshot_file( + file.path(asset_dir, "codebook.js"), + name = "codebook.js", + cran = TRUE + ) }) test_that("view_codebook includes section descriptions", { diff --git a/tests/testthat/test-quality-report.R b/tests/testthat/test-quality-report.R index 36e935d..d95e02a 100644 --- a/tests/testthat/test-quality-report.R +++ b/tests/testthat/test-quality-report.R @@ -50,6 +50,21 @@ test_that("build_quality_report requires REDCap API credentials", { build_quality_report(redcap_uri = "", token = "", progress = "none"), "redcap_uri" ) + expect_error( + build_quality_report(redcap_uri = 1, token = 2, progress = "none"), + "redcap_uri" + ) +}) + +test_that("build_quality_report validates scalar finite thresholds", { + expect_error( + build_quality_report(sparse_threshold = NA_real_, progress = "none"), + "sparse_threshold" + ) + expect_error( + build_quality_report(outlier_iqr_multiplier = c(1, 2), progress = "none"), + "outlier_iqr_multiplier" + ) }) test_that("pull_redcap_project quiets REDCapR API messages", { @@ -108,7 +123,7 @@ test_that("pull_redcap_project quiets REDCapR API messages", { ) }) -test_that("pull_redcap_project tolerates optional API failures but not required failures", { +test_that("pull_redcap_project reports optional and required API failures", { testthat::local_mocked_bindings( redcap_read_oneshot = function(redcap_uri, token, verbose, ...) { list(data = tibble::tibble(record_id = "1")) @@ -137,17 +152,14 @@ test_that("pull_redcap_project tolerates optional API failures but not required } ) - project <- pull_redcap_project( - redcap_uri = "https://redcap.example/api/", - token = "test-token" + expect_error( + pull_redcap_project( + redcap_uri = "https://redcap.example/api/", + token = "test-token" + ), + "event metadata" ) - expect_equal(nrow(project$data), 1) - expect_equal(project$events, tibble::tibble()) - expect_equal(project$event_instruments, tibble::tibble()) - expect_equal(project$instruments, tibble::tibble()) - expect_equal(project$repeating_instruments, tibble::tibble()) - testthat::local_mocked_bindings( redcap_read_oneshot = function(...) { stop("required records failed") @@ -163,6 +175,42 @@ test_that("pull_redcap_project tolerates optional API failures but not required ) }) +test_that("API repeating configuration does not depend on exported rows", { + redcap_data <- tibble::tibble( + record_id = character(), + repeat_value = character(), + repeat_form_complete = integer() + ) + metadata <- tibble::tibble( + field_name = c("record_id", "repeat_value"), + form_name = c("main", "repeat_form"), + field_type = "text", + field_label = c("Record ID", "Repeat Value"), + required_field = "n" + ) + repeating_instruments <- tibble::tibble( + event_name = "Event 1", + arm_num = 1L, + unique_event_name = "event_1_arm_1", + form_name = "repeat_form", + custom_form_label = NA_character_ + ) + + report <- build_mock_quality_report( + data = redcap_data, + metadata = metadata, + repeating_instruments = repeating_instruments, + checks = "missingness" + ) + + expect_true(report$summaries$project$repeating_enabled) + expect_equal( + report$metadata$repeating_instruments$redcap_event_name, + "event_1_arm_1" + ) + expect_equal(nrow(report$findings), 0) +}) + test_that("build_quality_report returns expected findings and summaries for API data", { redcap_data <- tibble::tibble( record_id = c("1", "2", "2", "3"), @@ -218,7 +266,6 @@ test_that("build_quality_report returns expected findings and summaries for API "event_instruments", "instruments", "repeating_instruments", - "instrument_structure", "choices", "validation", "branching", @@ -417,6 +464,7 @@ test_that("API field summaries ignore structural missingness from repeating inst report <- build_mock_quality_report( data = redcap_data, metadata = metadata, + repeating_instruments = tibble::tibble(form_name = "repeating_form"), checks = "missingness" ) @@ -428,7 +476,7 @@ test_that("API field summaries ignore structural missingness from repeating inst dplyr::pull(.data$missing_rate), c(0, 0) ) - expect_equal(report$summaries$records$missing_field_count, c(0, 0, 0)) + expect_equal(report$summaries$records$missing_field_count, 0) }) test_that("API project and form missing rates are weighted missing fractions", { @@ -483,6 +531,7 @@ test_that("API operational checks ignore structural completion blanks from repea report <- build_mock_quality_report( data = redcap_data, metadata = metadata, + repeating_instruments = tibble::tibble(form_name = "repeating_form"), checks = "operational" ) @@ -552,6 +601,30 @@ test_that("API checkbox consistency findings use REDCap checkbox columns", { expect_equal(report$findings$value, "symptoms___none") }) +test_that("API checkbox consistency uses metadata choice values", { + redcap_data <- tibble::tibble( + record_id = c("1", "2"), + symptoms___99 = c(1, 0), + symptoms___1 = c(1, 1) + ) + metadata <- tibble::tibble( + field_name = c("record_id", "symptoms"), + form_name = "main", + field_type = c("text", "checkbox"), + field_label = c("Record ID", "Symptoms"), + select_choices_or_calculations = c(NA, "99, None | 1, Fever") + ) + + report <- build_mock_quality_report( + data = redcap_data, + metadata = metadata, + checks = "consistency" + ) + + expect_equal(report$findings$record_id, "1") + expect_equal(report$findings$value, "symptoms___99") +}) + test_that("API checkbox consistency findings include repeating instance context", { redcap_data <- tibble::tibble( record_id = c("1", "1", "1"), @@ -576,6 +649,7 @@ test_that("API checkbox consistency findings include repeating instance context" report <- build_mock_quality_report( data = redcap_data, metadata = metadata, + repeating_instruments = tibble::tibble(form_name = "symptom_log"), checks = "consistency" ) @@ -982,6 +1056,9 @@ test_that("API field summaries ignore structural missingness between repeating i report <- build_mock_quality_report( data = redcap_data, metadata = metadata, + repeating_instruments = tibble::tibble( + form_name = c("repeat_a", "repeat_b") + ), checks = "missingness" ) @@ -994,7 +1071,7 @@ test_that("API field summaries ignore structural missingness between repeating i dplyr::pull(.data$record_count), c(1, 1) ) - expect_equal(report$summaries$records$missing_field_count, c(0, 0, 0)) + expect_equal(report$summaries$records$missing_field_count, 0) }) test_that("API repeating missingness requires matching non-missing form status", { @@ -1019,6 +1096,7 @@ test_that("API repeating missingness requires matching non-missing form status", report <- build_mock_quality_report( data = redcap_data, metadata = metadata, + repeating_instruments = tibble::tibble(form_name = "repeat_a"), checks = "missingness" ) @@ -1066,6 +1144,7 @@ test_that("API completion findings preserve form order around repeating forms", report <- build_mock_quality_report( data = raw_data, metadata = metadata, + repeating_instruments = tibble::tibble(form_name = "repeated"), checks = "operational" ) @@ -1289,6 +1368,58 @@ test_that("API required missingness respects simple branching logic", { expect_equal(report$summaries$records$missing_field_count, c(1, 0, 0)) }) +test_that("API record summaries aggregate longitudinal rows", { + redcap_data <- tibble::tibble( + record_id = c("1", "1", "2"), + redcap_event_name = c("baseline_arm_1", "followup_arm_1", "baseline_arm_1"), + required_value = c(NA, NA, "present"), + main_complete = 2 + ) + metadata <- tibble::tibble( + field_name = c("record_id", "required_value"), + form_name = "main", + field_type = "text", + field_label = c("Record ID", "Required Value"), + required_field = "y" + ) + + report <- build_mock_quality_report( + data = redcap_data, + metadata = metadata, + checks = "missingness" + ) + + expect_equal(report$summaries$records$record_id, c("1", "2")) + expect_equal(report$summaries$records$missing_field_count, c(2, 0)) + expect_equal(report$summaries$records$finding_count, c(2L, 0L)) +}) + +test_that("API IQR checks exclude numeric-coded categorical fields", { + redcap_data <- tibble::tibble( + record_id = as.character(seq_len(6)), + category = c(1, 2, 2, 3, 3, 99), + main_complete = 2 + ) + metadata <- tibble::tibble( + field_name = c("record_id", "category"), + form_name = "main", + field_type = c("text", "radio"), + field_label = c("Record ID", "Category"), + select_choices_or_calculations = c( + NA, + "1, A | 2, B | 3, C | 99, Other" + ) + ) + + report <- build_mock_quality_report( + data = redcap_data, + metadata = metadata, + checks = "outliers" + ) + + expect_false("numeric_iqr_outlier" %in% report$findings$issue) +}) + test_that("API required missingness treats NA branching evaluations as hidden", { redcap_data <- tibble::tibble( record_id = c("1", "2", "3"), diff --git a/tests/testthat/test-record-status-dashboard.R b/tests/testthat/test-record-status-dashboard.R index 2fc9671..9cf5e08 100644 --- a/tests/testthat/test-record-status-dashboard.R +++ b/tests/testthat/test-record-status-dashboard.R @@ -23,24 +23,24 @@ build_mock_status_dashboard <- function( } ) - get_record_status_data( + build_record_status_data( redcap_uri = "https://redcap.example/api/", token = "test-token" ) } -test_that("get_record_status_data requires REDCap API credentials", { +test_that("build_record_status_data requires REDCap API credentials", { expect_error( - get_record_status_data(), + build_record_status_data(), "redcap_uri" ) expect_error( - get_record_status_data(redcap_uri = "", token = ""), + build_record_status_data(redcap_uri = "", token = ""), "redcap_uri" ) }) -test_that("get_record_status_data returns classic dashboard tiles", { +test_that("build_record_status_data returns classic dashboard tiles", { redcap_data <- tibble::tibble( record_id = c("1", "2"), demographic_value = c("present", "present"), @@ -74,7 +74,7 @@ test_that("get_record_status_data returns classic dashboard tiles", { expect_equal(dashboard$pct_complete, c(1, NA, 1, 0)) }) -test_that("get_record_status_data returns longitudinal event-form tiles", { +test_that("build_record_status_data returns longitudinal event-form tiles", { redcap_data <- tibble::tibble( infseq_id = c("A", "A", "B", "B"), redcap_event_name = c( @@ -138,7 +138,7 @@ test_that("get_record_status_data returns longitudinal event-form tiles", { ) }) -test_that("get_record_status_data averages repeating instrument instances", { +test_that("build_record_status_data averages repeating instrument instances", { redcap_data <- tibble::tibble( record_id = c("1", "1", "1", "2"), redcap_repeat_instrument = c(NA, "repeat_form", "repeat_form", NA), @@ -158,7 +158,8 @@ test_that("get_record_status_data averages repeating instrument instances", { dashboard <- build_mock_status_dashboard( data = redcap_data, - metadata = metadata + metadata = metadata, + repeating_instruments = tibble::tibble(form_name = "repeat_form") ) expect_equal( @@ -175,7 +176,7 @@ test_that("get_record_status_data averages repeating instrument instances", { ) }) -test_that("get_record_status_data includes repeating-instrument-only events", { +test_that("build_record_status_data includes repeating-instrument-only events", { redcap_data <- tibble::tibble( record_id = c("1", "1", "1"), redcap_event_name = c( @@ -217,7 +218,10 @@ test_that("get_record_status_data includes repeating-instrument-only events", { ) repeating_instruments <- tibble::tibble( - event_name = c("repeating_event_arm_1", "nonrepeating_event_arm_1"), + unique_event_name = c( + "repeating_event_arm_1", + "nonrepeating_event_arm_1" + ), form_name = c(NA_character_, "repeat_form") ) @@ -399,13 +403,32 @@ test_that("plot_record_status truncates long compact form labels", { expect_equal(levels(standard_plot$data$form_name), long_label) expect_equal( - levels(compact_plot$data$form_name), + ggplot2::ggplot_build(compact_plot)$layout$panel_params[[1]]$x$get_labels(), stringr::str_trunc(long_label, width = 35) ) expect_equal( - levels(override_plot$data$form_name), + ggplot2::ggplot_build(override_plot)$layout$panel_params[[1]]$x$get_labels(), stringr::str_trunc(long_label, width = 20) ) + expect_equal(levels(compact_plot$data$form_name), long_label) +}) + +test_that("plot_record_status keeps truncated form labels distinct", { + labels <- c( + "A very long form label whose shared prefix is identical one", + "A very long form label whose shared prefix is identical two" + ) + dashboard <- tibble::tibble( + record_id = "1", + form_name = labels, + pct_complete = c(1, 0) + ) + + plot <- plot_record_status(dashboard, mode = "compact", form_label_max = 35) + built_plot <- ggplot2::ggplot_build(plot) + + expect_equal(nlevels(plot$data$form_name), 2) + expect_equal(built_plot$data[[1]]$x, c(1, 2)) }) test_that("plot_record_status compact mode preserves explicit overrides", { @@ -486,4 +509,10 @@ test_that("plot_record_status validates dashboard data", { ), "y_tick_every" ) + expect_error( + plot_record_status( + tibble::tibble(record_id = "1", form_name = "main", pct_complete = 1.1) + ), + "between 0 and 1" + ) }) diff --git a/vignettes/REDCapExploreR.Rmd b/vignettes/REDCapExploreR.Rmd index 11df418..bcb890b 100644 --- a/vignettes/REDCapExploreR.Rmd +++ b/vignettes/REDCapExploreR.Rmd @@ -53,13 +53,13 @@ API tokens directly in scripts, vignettes, or version-controlled files. ## Record Status Dashboard -Use `get_record_status_data()` to retrieve a plotting-friendly table that +Use `build_record_status_data()` to retrieve a plotting-friendly table that summarizes REDCap form completion status by record. Then pass the result to `plot_record_status()` to create a ggplot heat map similar to the REDCap Record Status Dashboard. ```{r record-status-api, eval=FALSE} -status_data <- get_record_status_data( +status_data <- build_record_status_data( redcap_uri = redcap_uri, token = token ) @@ -70,7 +70,7 @@ plot_record_status(status_data) The package includes a small synthetic dataset for examples and offline exploration. `mock_record_status_data` is generated from `mock_redcap_project` using the same internal record-status logic that supports -`get_record_status_data()`. +`build_record_status_data()`. ```{r record-status-mock, fig.alt="Synthetic REDCap record status dashboard heat map."} mock_record_status_data @@ -183,11 +183,15 @@ report <- build_quality_report( ) ``` +See the [quality report article](quality-report.html) for a complete reference +to report checks, output elements, interpretation workflows, and core +assumptions. + ## Pulling project data -`pull_redcap_project()` retrieves the raw REDCap records and project metadata +`pull_redcap_project()` retrieves the raw REDCap records and structural metadata used by the quality reporting workflow. This can be useful when you need the -same normalized API pull for custom exploration. +same API tables for custom exploration. ```{r pull-project-api, eval=FALSE} project <- pull_redcap_project( diff --git a/vignettes/articles/quality-report.Rmd b/vignettes/articles/quality-report.Rmd new file mode 100644 index 0000000..281066b --- /dev/null +++ b/vignettes/articles/quality-report.Rmd @@ -0,0 +1,381 @@ +--- +title: "Understanding REDCap Quality Reports" +output: rmarkdown::html_document +--- + +```{r, include = FALSE} +knitr::opts_chunk$set( + collapse = TRUE, + comment = "#>" +) +``` + +```{r setup} +library(REDCapExploreR) +``` + +`build_quality_report()` retrieves REDCap records and project structure, applies +general data quality checks, and organizes the results for review. The report is +intended to help data managers and analysts identify records, fields, and +metadata that warrant attention. It does not modify the REDCap project. + +This article explains how to build and navigate a report. All evaluated +examples use `mock_quality_report`, a synthetic report included with the +package, so they do not require REDCap credentials. + +## How to Use + +### Build a report + +Store the REDCap API URI and token outside version-controlled code, such as in +environment variables, and pass them to `build_quality_report()`: + +```{r build-report, eval=FALSE} +report <- build_quality_report( + redcap_uri = Sys.getenv("REDCAP_URI"), + token = Sys.getenv("REDCAP_TOKEN") +) +``` + +`checks` controls which findings are generated. Summaries and interpreted +metadata are still returned, while `finding_count` values in the project and +record summaries reflect the selected check groups. `mock_quality_report` was +built with all default check groups and thresholds. + +The default call runs all five check groups: `missingness`, `metadata`, +`outliers`, `operational`, and `consistency`. Use `checks` to run a focused +review: + +```{r focused-report, eval=FALSE} +report <- build_quality_report( + redcap_uri = Sys.getenv("REDCAP_URI"), + token = Sys.getenv("REDCAP_TOKEN"), + checks = c("missingness", "metadata"), + progress = "none" +) +``` + +Two arguments control heuristic thresholds: + +- `sparse_threshold` is the applicable-row missing rate at which a field is + reported as unexpectedly sparse. It defaults to `0.95`. +- `outlier_iqr_multiplier` controls the distance from the first and third + quartiles used by the IQR outlier check. It defaults to `3`. + +`progress = "auto"` displays progress in interactive sessions. Use `"show"` to +force it or `"none"` to suppress it. + +### Start with the report overview + +Printing the report gives a short project-level overview. The object itself is +a list with `findings`, `summaries`, and `metadata` elements. + +```{r report-overview} +mock_quality_report + +names(mock_quality_report) +names(mock_quality_report$summaries) +names(mock_quality_report$metadata) +``` + +Begin with the project summary and a count of findings by check and issue. This +shows the size and structure of the project alongside the types of review items +that were found. + +```{r report-triage} +mock_quality_report$summaries$project + +mock_quality_report$findings |> + dplyr::count(check, issue, severity, sort = TRUE) +``` + +### Review actionable findings + +Use `scope` to distinguish findings tied to a specific record and field from +field-level metadata findings. The context columns remain available for +longitudinal and repeating projects. + +```{r record-findings} +record_findings <- mock_quality_report$findings |> + dplyr::filter(scope == "record_field") |> + dplyr::select( + finding_id, + severity, + issue, + record_id, + event_name, + repeat_instrument, + repeat_instance, + field_name, + value, + expected + ) + +record_findings +``` + +Filter by record, issue, form, or severity to create a review queue. Severity is +a package-provided prioritization aid, not a clinical or regulatory +classification. + +```{r review-one-record} +mock_quality_report$findings |> + dplyr::filter(record_id == "C002") |> + dplyr::select( + finding_id, + severity, + issue, + form_name, + field_name, + value, + expected, + message + ) +``` + +Join findings to interpreted field metadata when labels and field types are +useful during review: + +```{r enrich-findings} +field_reference <- mock_quality_report$metadata$fields |> + dplyr::select(field_name, field_label, field_type, required_field) + +mock_quality_report$findings |> + dplyr::filter(!is.na(field_name)) |> + dplyr::inner_join(field_reference, by = "field_name") |> + dplyr::select( + finding_id, + issue, + record_id, + field_name, + field_label, + field_type, + required_field + ) +``` + +## Key Elements + +### Findings + +`findings` is a standardized tibble with one row per detected issue. A report +with no findings returns an empty tibble with the same columns. + +| Column | Meaning | +|---|---| +| `finding_id` | Sequential identifier assigned within the report. | +| `check` | Check group that produced the finding. | +| `issue` | Stable issue name suitable for filtering and counting. | +| `severity` | Package-assigned `warning` or `info` review priority. | +| `scope` | `record_field` for record-level findings or `field` for metadata and aggregate findings. | +| `record_id` | REDCap record identifier when the finding is record-specific. | +| `form_name` | REDCap instrument associated with the finding. | +| `event_name` | Unique REDCap event name when available. | +| `repeat_instrument` | Repeating instrument name when available. | +| `repeat_instance` | Repeating instance number when available. | +| `field_name` | REDCap field associated with the finding. | +| `value` | Observed value or aggregate value that triggered the finding. | +| `expected` | Concise description of the expected state or range. | +| `message` | Human-readable explanation of the finding. | + +The following checks are currently performed. + +| Check | Issue | What it identifies | Severity and scope | +|---|---|---|---| +| Missingness | `required_field_missing` | A required field is blank on a row where the form and branching logic are applicable and completion status permits missingness assessment. | Warning, `record_field` | +| Missingness | `unexpected_sparse_field` | A field's missing rate is at or above `sparse_threshold` among its applicable rows. | Info, field | +| Metadata | `duplicate_field_label` | The same nonblank field label is used by multiple field names. | Info, field | +| Metadata | `missing_field_label` | A field has no label. | Info, field | +| Metadata | `missing_choice_definition` | A radio, dropdown, or checkbox field has no explicit choice definition. | Warning, field | +| Metadata | `orphaned_branching_reference` | Branching logic references a field not present in project metadata. | Warning, field | +| Metadata | `high_risk_free_text` | A text or notes field name or label contains terms associated with notes, comments, other details, or similar free text. | Info, field | +| Outliers | `outside_validation_range` | A numeric value is below or above a REDCap metadata validation bound. | Warning, `record_field` | +| Outliers | `numeric_iqr_outlier` | A numeric, non-choice field falls outside the configured IQR heuristic. At least four observed values and a nonzero IQR are required. | Info, `record_field` | +| Outliers | `future_date` | A field with date validation contains a `YYYY-MM-DD` value after the report date. | Info, `record_field` | +| Operational | `incomplete_form_status` | An applicable form status is missing or is not Complete. | Info, `record_field` | +| Consistency | `checkbox_none_with_other` | A checkbox option labeled as none, no, or not applicable is selected with another option. | Warning, `record_field` | + +Choice fields are excluded from IQR outlier detection even when their codes are +numeric. Yes/No and True/False fields have implicit REDCap choices, so they do +not require an explicit choice definition. + +### Summaries + +`summaries` provides five views of the normalized project and report findings. + +#### Project + +`summaries$project` is a one-row overview: + +- `record_count`: number of distinct record identifiers. +- `raw_row_count`: number of rows returned by the records API, including event + and repeating rows. +- `field_count`, `form_count`, and `event_count`: project structure counts. +- `repeating_enabled`: whether repeating configuration was returned by REDCap. +- `missing_rate`: weighted missing fraction across applicable field-row cells. +- `finding_count`: total number of findings from the selected check groups. + +```{r project-summary} +mock_quality_report$summaries$project +``` + +#### Forms and fields + +`summaries$forms` reports field counts, required-field counts, and weighted +missing rates by form. `summaries$fields` gives the most detailed missingness +profile: + +- `record_count` is the number of applicable rows assessed for the field. It is + not necessarily the number of distinct records because longitudinal and + repeating projects can contribute multiple rows per record. +- `missing_count` and `observed_count` partition applicable rows. +- `missing_rate` is `missing_count / record_count`, or `NA` when no rows are + applicable. +- `distinct_count` counts unique nonmissing values. + +```{r field-summaries} +mock_quality_report$summaries$forms + +mock_quality_report$summaries$fields |> + dplyr::arrange(dplyr::desc(missing_rate), field_name) |> + dplyr::select( + field_name, + form_name, + required_field, + record_count, + missing_count, + missing_rate, + distinct_count + ) +``` + +Project and form missing rates are weighted across applicable field-row cells. +They are not simple averages of field-level percentages. + +#### Records and events + +`summaries$records` contains one row per record. `missing_field_count` sums +missing applicable field values across that record's events and repeat +instances, while `finding_count` counts record-specific findings. Field-level +metadata findings do not belong to an individual record. + +`summaries$events` counts raw API rows by unique event name. It is empty for +nonlongitudinal projects. + +```{r record-event-summaries} +mock_quality_report$summaries$records |> + dplyr::arrange(dplyr::desc(finding_count), record_id) + +mock_quality_report$summaries$events +``` + +### Metadata + +`metadata` preserves and interprets project structure used by the checks. + +| Element | Contents | +|---|---| +| `fields` | Standardized REDCap field metadata used by the report. | +| `forms` | One row per form with field, required-field, and choice-field counts. | +| `events` | Standardized longitudinal event metadata. | +| `event_instruments` | Standardized event-to-form mapping. | +| `instruments` | Instrument names and labels returned by REDCap. | +| `repeating_instruments` | Repeating event and instrument configuration returned by REDCap, with normalized join columns. | +| `choices` | Parsed explicit choices and implicit Yes/No and True/False choices. | +| `validation` | Fields with validation types or minimum/maximum bounds. | +| `branching` | Fields with branching logic and parsed referenced field names. | +| `record_id_field` | Field name used as the project record identifier. | +| `source` | Source of the normalized project; API reports use `"api"`. | + +These tables are useful for interpreting findings and for project-level review +that is not represented by a finding. For example, inspect repeating structure +and validation rules directly: + +```{r metadata-examples} +mock_quality_report$metadata$repeating_instruments + +mock_quality_report$metadata$validation + +mock_quality_report$metadata$branching +``` + +## Core Assumptions + +### Findings are review prompts + +The checks are broad heuristics intended to identify items worth reviewing. +They do not establish clinical correctness, protocol compliance, or regulatory +compliance. An `info` finding can still be important, and a report with no +findings does not prove that the data are correct. + +`finding_id` is assigned when a report is built and should not be treated as a +persistent identifier across runs. Use issue and REDCap context columns when +tracking findings externally. + +### Missingness is assessed only where a field is applicable + +`NA` and blank or whitespace-only values are treated as missing. For checkbox +fields, one or more checked options make the field observed; a set of unchecked +exports is treated as missing when the field is assessed. + +Applicability combines several pieces of project structure: + +1. Longitudinal forms are limited to events in the event-to-form mapping. +2. Repeating instruments are identified from REDCap repeating configuration, + not inferred solely from rows currently present in the data export. +3. Repeating rows are matched to their configured instrument so structural + blanks from other repeating instruments are not counted as missing. +4. Branching logic limits assessment to rows where the field should be shown. +5. Missingness is assessed only when `_complete` is Unverified (`1`) + or Complete (`2`). Incomplete (`0`) forms are handled by the operational + check instead. + +This design avoids treating structural blanks as missing data. It also means a +field can have `record_count = 0` and `missing_rate = NA` when no exported rows +are eligible for assessment. + +### Branching logic support is intentionally bounded + +The branching evaluator supports ordinary field comparisons, checkbox choice +references, parentheses, and `and`/`or` combinations. A valid expression that +evaluates to `NA` is treated as not shown for that row. + +REDCap functions, smart variables, and other expressions outside the supported +subset are treated as applicable rather than used to hide a potentially +required value. The metadata check separately reports references to fields that +are absent from project metadata. Complex branching findings should therefore +be reviewed against REDCap itself. + +### Outlier checks depend on metadata and sample size + +Validation-range findings depend on numeric minimum and maximum values defined +in REDCap metadata. IQR findings are distribution-based and require at least +four observed values and a nonzero IQR. Their usefulness depends on the field's +distribution and the selected multiplier. + +Future-date checks apply only to fields whose REDCap validation type contains +`date`, and values are interpreted in `YYYY-MM-DD` form. Invalid date strings +are not reported as future dates. + +### Completion status drives two different questions + +Missingness asks whether expected values are present on forms sufficiently +complete to assess. The operational check asks whether accessible forms have a +Complete status. For that reason, a form marked Incomplete can produce an +operational finding without contributing required-field missingness findings. + +### Counts reflect REDCap's row structure + +A REDCap record can produce multiple API rows because of events and repeating +instances. Project `record_count` counts distinct records, `raw_row_count` +counts exported rows, field `record_count` counts applicable rows, and event +`row_count` counts exported rows per event. Choose the measure that matches the +question being asked. + +### The report depends on current REDCap structure + +Descriptive fields are removed before analysis because they do not store record +values. Structural metadata retrieval failures are reported as errors rather +than silently converted to empty project structure. Results reflect the API +records and metadata available when the report is built, including the token's +project access and export configuration.