diff --git a/.Rbuildignore b/.Rbuildignore index d769952..188d009 100644 --- a/.Rbuildignore +++ b/.Rbuildignore @@ -10,3 +10,4 @@ $run_dev.* ^LICENSE\.md$ ^\.github$ ^\.lintr$ +^\.renvignore$ diff --git a/DESCRIPTION b/DESCRIPTION index ef66bfb..dee77f1 100644 --- a/DESCRIPTION +++ b/DESCRIPTION @@ -13,7 +13,9 @@ Imports: shiny (>= 1.9.1), DBI, duckdb, - glue + glue, + tidyr, + withr Suggests: devtools, usethis, diff --git a/R/app_server.R b/R/app_server.R index 9984fe9..ee79e2c 100644 --- a/R/app_server.R +++ b/R/app_server.R @@ -5,24 +5,20 @@ #' @import shiny #' @noRd app_server <- function(input, output, session) { - # TODO: to be replaced by real data, which should be reactive so it responds to filtering options - mock_data <- data.frame( - concept_id = c(2212648, 2617206, 2212406), - name = c( - "Blood count; complete (CBC), automated (Hgb, Hct, RBC, WBC and platelet count) and automated differential WBC count", # nolint - "Prostate specific antigen test (psa)", - "Homocysteine" - ), - person_count = c(7080, 960, 10), - records_per_person = c(4.37, 1.12, 1.06) - ) - mock_data <- reactiveVal(mock_data) + # Get the input tables + concepts_table <- get_concepts_table() + monthly_counts <- get_monthly_counts() + summary_stats <- get_summary_stats() - selected_data <- mod_select_concepts_server("select_concepts", mock_data) + selected_data <- mod_select_concepts_server("select_concepts", concepts_table) + mod_date_range_server("date_range") + + selected_row <- mod_datatable_server("totals", selected_data) + + # TODO: refactor monthly_count and stat_numeric modules into a single module + # https://github.com/UCLH-Foundry/omop-data-catalogue/issues/30 + mod_monthly_count_server("monthly_count", monthly_counts, selected_row) + mod_stat_numeric_server("stat_numeric", summary_stats, selected_row) - mod_date_range_server("date_range_1") - mod_datatable_server("totals", selected_data) - mod_monthly_count_server("monthly_count_1") - mod_stat_numeric_server("stat_numeric_1") mod_export_tab_server("export_tab", selected_data) } diff --git a/R/app_ui.R b/R/app_ui.R index 120fcfb..26ca545 100644 --- a/R/app_ui.R +++ b/R/app_ui.R @@ -15,14 +15,14 @@ app_ui <- function(request) { sidebar = sidebar( title = "Filtering options", mod_select_concepts_ui("select_concepts"), - mod_date_range_ui("date_range_1"), + mod_date_range_ui("date_range"), ), nav_panel( title = "Dashboard", card(mod_datatable_ui("totals")), layout_columns( - card(mod_monthly_count_ui("monthly_count_1")), - card(mod_stat_numeric_ui("stat_numeric_1")) + card(mod_monthly_count_ui("monthly_count")), + card(mod_stat_numeric_ui("stat_numeric")) ) ), nav_panel( diff --git a/R/fct_monthly_count_plot.R b/R/fct_monthly_count_plot.R index 26700f4..c833e50 100644 --- a/R/fct_monthly_count_plot.R +++ b/R/fct_monthly_count_plot.R @@ -1,17 +1,34 @@ #' monthly_count_plot #' -#' @description A fct function +#' Generates a bar plot of the number of records per month for a given concept. #' -#' @return The return value, if any, from executing the function. +#' Expects the input data to have the following columns: +#' - `date_year`: The year of the date. +#' - `date_month`: The month of the date. +#' - `person_count`: The number of records for the given month. #' -#' @importFrom ggplot2 ggplot aes geom_bar ggtitle xlab ylab +#' @param monthly_counts A data frame containing the monthly counts. +#' @param plot_title The title for the plot. +#' +#' @return A ggplot2 object containing the bar plot, or `NULL` if no data is provided. #' +#' @importFrom ggplot2 ggplot aes geom_bar ggtitle xlab ylab #' @noRd -monthly_count_plot <- function(monthly_counts, name) { - date <- record_count <- NULL - ggplot(monthly_counts, aes(x = date, y = record_count)) + +monthly_count_plot <- function(monthly_counts, plot_title) { + stopifnot(is.data.frame(monthly_counts)) + stopifnot(is.character(plot_title)) + stopifnot(all(c("date_year", "date_month", "person_count") %in% colnames(monthly_counts))) + + monthly_counts$date <- .convert_to_date(monthly_counts$date_year, monthly_counts$date_month) + + date <- person_count <- NULL + ggplot(monthly_counts, aes(x = date, y = person_count)) + geom_bar(stat = "identity") + - ggtitle(name) + + ggtitle(plot_title) + xlab("Month") + ylab("Number of records") } + +.convert_to_date <- function(date_year, date_month) { + as.Date(paste0(date_year, "-", date_month, "-01")) +} diff --git a/R/fct_stat_numeric_plot.R b/R/fct_stat_numeric_plot.R index d10b3cf..7d3b2f5 100644 --- a/R/fct_stat_numeric_plot.R +++ b/R/fct_stat_numeric_plot.R @@ -1,14 +1,25 @@ #' stat_numeric_plot #' -#' @description A fct function +#' Generates a boxplot of the summary statistics for a numeric concept. +#' Uses pre-calculated `mean` and `sd` to generate the boxplot. #' -#' @return The return value, if any, from executing the function. +#' Expects the input data to have the following columns: +#' - `concept_id`: The concept ID. +#' - `summary_attribute`: The type of the summary attribute, e.g. `mean` or `sd`. +#' - `value_as_number`: The value of the summary attribute as a numeric value. +#' +#' @param summary_stats A `data.frame` containing the summary statistics. +#' @param plot_title A `character`, to be used as title of the plot. +#' +#' @return A `ggplot2` object. #' #' @importFrom ggplot2 ggplot aes geom_boxplot #' @noRd -stat_numeric_plot <- function(summary_stat) { - mean <- sd <- concept <- NULL - ggplot(summary_stat, aes(x = concept)) + +stat_numeric_plot <- function(summary_stats, plot_title) { + processed_stats <- .process_summary_stats(summary_stats) + + mean <- sd <- concept_id <- NULL + ggplot(processed_stats, aes(x = factor(concept_id))) + geom_boxplot( aes( lower = mean - sd, @@ -18,5 +29,21 @@ stat_numeric_plot <- function(summary_stat) { ymax = mean + 3 * sd ), stat = "identity" - ) + ) + + xlab(NULL) + + ggtitle(plot_title) +} + +.process_summary_stats <- function(summary_stats) { + # We expect only single concept ID at this point + # NOTE: this might change when we support bundles of concepts, in which case we might want to + # display the entire batch in one plot + stopifnot("Expecting a single concept ID" = length(unique(summary_stats$concept_id)) == 1) + stopifnot(c("concept_id", "summary_attribute", "value_as_number") %in% names(summary_stats)) + + tidyr::pivot_wider(summary_stats, + id_cols = "concept_id", + names_from = "summary_attribute", + values_from = "value_as_number" + ) } diff --git a/R/mod_datatable.R b/R/mod_datatable.R index 117d4d9..014ba8e 100644 --- a/R/mod_datatable.R +++ b/R/mod_datatable.R @@ -18,6 +18,8 @@ mod_datatable_ui <- function(id) { #' #' @param data A reactive data.frame containing the data to be displayed #' +#' @return The selected row as a reactive object +#' #' @noRd mod_datatable_server <- function(id, data) { stopifnot(is.reactive(data)) @@ -28,5 +30,7 @@ mod_datatable_server <- function(id, data) { selected = 1, target = "row" )) + + reactive(data()[input$datatable_rows_selected, ]) }) } diff --git a/R/mod_monthly_count.R b/R/mod_monthly_count.R index 734d37d..6e44814 100644 --- a/R/mod_monthly_count.R +++ b/R/mod_monthly_count.R @@ -1,6 +1,6 @@ #' monthly_count UI Function #' -#' @description A shiny Module. +#' Displays the monthly count plot. #' #' @param id,input,output,session Internal parameters for {shiny}. #' @@ -16,18 +16,32 @@ mod_monthly_count_ui <- function(id) { #' monthly_count Server Functions #' +#' Generates the monthly count plot for a given concept. When no concept was selected, +#' an empty plot is generated. +#' +#' @param data `data.frame` containing the data to be plotted. +#' @param selected_concept Reactive value containing the selected concept, used for filtering +#' #' @noRd -mod_monthly_count_server <- function(id) { +mod_monthly_count_server <- function(id, data, selected_concept) { + stopifnot(is.data.frame(data)) + stopifnot(is.reactive(selected_concept)) + + moduleServer(id, function(input, output, session) { - # TODO: how to get the data? - # Need to get the input data and filter it based on the selected timeframe and selected row - # see https://stackoverflow.com/a/77039776/11801854 - monthly_count <- data.frame( - date = c("2020-01", "2020-02", "2020-03", "2020-04"), - record_count = c(120, 250, 281, 220) - ) + ## Filter data based on selected_row + selected_concept_id <- reactive(selected_concept()$concept_id) + selected_concept_name <- reactive(selected_concept()$concept_name) + filtered_monthly_counts <- reactive({ + if (!length(selected_concept_id())) { + return(NULL) + } + data[data$concept_id == selected_concept_id(), ] + }) output$monthly_count_plot <- renderPlot({ - monthly_count_plot(monthly_count, "SELECTED ROW") + ## Return empty plot if no data is selected + if (is.null(filtered_monthly_counts())) return(NULL) + monthly_count_plot(filtered_monthly_counts(), selected_concept_name()) }) }) } diff --git a/R/mod_select_concepts.R b/R/mod_select_concepts.R index 0ea929a..141f7d7 100644 --- a/R/mod_select_concepts.R +++ b/R/mod_select_concepts.R @@ -16,19 +16,24 @@ mod_select_concepts_ui <- function(id) { #' select_concepts Server Functions #' -#' @param data A reactive data.frame containing the data from which to select the concepts +#' @param concepts_table A reactive data.frame containing the data from which to select the concepts #' #' @return A reactive data.frame filtered on the selected concepts #' #' @noRd -mod_select_concepts_server <- function(id, data) { - stopifnot(is.reactive(data)) +mod_select_concepts_server <- function(id, concepts_table) { + stopifnot("concept_name" %in% names(concepts_table)) moduleServer(id, function(input, output, session) { - observeEvent(data(), { - updateSelectInput(session, "select_concepts", choices = data()$name, selected = data()$name) + ## Make concepts table reactive so that it can be updated + concepts_table <- reactiveVal(concepts_table) + observeEvent(concepts_table(), { + updateSelectInput(session, "select_concepts", + choices = concepts_table()$concept_name, + ## Have all present concepts selected by default + selected = concepts_table()$concept_name + ) }) - - reactive(data()[data()$name %in% input$select_concepts, ]) + reactive(concepts_table()[concepts_table()$concept_name %in% input$select_concepts, ]) }) } diff --git a/R/mod_stat_numeric.R b/R/mod_stat_numeric.R index 556c3ba..fb98557 100644 --- a/R/mod_stat_numeric.R +++ b/R/mod_stat_numeric.R @@ -1,6 +1,6 @@ #' stat_numeric UI Function #' -#' @description A shiny Module. +#' Displays the boxplot of the summary statistics for a numeric concept. #' #' @param id,input,output,session Internal parameters for {shiny}. #' @@ -16,18 +16,34 @@ mod_stat_numeric_ui <- function(id) { #' stat_numeric Server Functions #' +#' Generates the boxplot of the summary statistics for a numeric concept. +#' When no concept was selected, an empty plot is returned. +#' +#' @param data `data.frame` containing the data to be plotted. +#' @param selected_concept Reactive value containing the selected concept, used for filtering +#' #' @noRd -mod_stat_numeric_server <- function(id) { +mod_stat_numeric_server <- function(id, data, selected_concept) { + stopifnot(is.data.frame(data)) + stopifnot(is.reactive(selected_concept)) + moduleServer(id, function(input, output, session) { - summary_stat <- data.frame( - concept = "SELECTED ROW", - sd = 0.8280661, - mean = 5.843 - ) + # Filter data based on the selected row + selected_concept_id <- reactive(selected_concept()$concept_id) + selected_concept_name <- reactive(selected_concept()$concept_name) + + ## When no row selected, return an empty plot + filtered_summary_stats <- reactive({ + if (!length(selected_concept_id())) { + return(NULL) + } + data[data$concept_id == selected_concept_id(), ] + }) output$stat_numeric_plot <- renderPlot({ - # TODO: move this to a separate function - stat_numeric_plot(summary_stat) + ## Return empty plot if no data is selected + if (is.null(filtered_summary_stats())) return(NULL) + stat_numeric_plot(filtered_summary_stats(), selected_concept_name()) }) }) } diff --git a/R/utils_get_data.R b/R/utils_get_data.R new file mode 100644 index 0000000..b759dfe --- /dev/null +++ b/R/utils_get_data.R @@ -0,0 +1,63 @@ +#' Get input data for the app +#' +#' Utility functions to retrieve the input data for the app from the database. +#' +#' @noRd +get_concepts_table <- function() { + if (golem::app_dev()) { + return(data.frame( + concept_id = c(40213251, 133834, 4057420), + concept_name = c( + "varicella virus vaccine", + "Atopic dermatitis", + "Catheter ablation of tissue of heart" + ), + domain_id = c("Drug", "Condition", "Procedure"), + vocabulary_id = c("CVX", "SNOMED", "SNOMED"), + concept_class_id = c("CVX", "Clinical Finding", "Procedure"), + standard_concept = c("S", "S", "S"), + concept_code = c("21", "24079001", "18286008") + )) + } + + con <- connect_to_test_db() + withr::defer(DBI::dbDisconnect(con)) + DBI::dbReadTable(con, "calypso_concepts") +} + +get_monthly_counts <- function() { + if (golem::app_dev()) { + return( + data.frame( + concept_id = c( + rep(c(40213251, 133834, 4057420), each = 3) + ), + date_year = c(2019L, 2020L, 2020L, 2019L, 2020L, 2020L, 2020L, 2019L, 2019L), + date_month = c(4L, 3L, 5L, 5L, 8L, 4L, 11L, 6L, 3L), + person_count = c(1, 1, 3, 4, 2, 3, 2, 4, 1), + records_per_person = c(1, 1, 1, 1, 1, 1, 1, 1, 1) + ) + ) + } + + con <- connect_to_test_db() + withr::defer(DBI::dbDisconnect(con)) + DBI::dbReadTable(con, "calypso_monthly_counts") +} + +get_summary_stats <- function() { + if (golem::app_dev()) { + return( + data.frame( + concept_id = rep(c(40213251, 133834, 4057420), each = 2), + summary_attribute = rep(c("mean", "sd"), times = 3), + value_as_string = rep(NA, 6), + value_as_number = c(1.5, 0.5, 2.5, 0.7, 3.5, 0.8) + ) + ) + } + + con <- connect_to_test_db() + withr::defer(DBI::dbDisconnect(con)) + DBI::dbReadTable(con, "calypso_summary_stats") +} diff --git a/tests/testthat/test-mod_datatable.R b/tests/testthat/test-mod_datatable.R new file mode 100644 index 0000000..f3a8cc7 --- /dev/null +++ b/tests/testthat/test-mod_datatable.R @@ -0,0 +1,42 @@ +mock_data <- data.frame( + concept_id = c(40213251, 133834, 4057420), + concept_name = c( + "varicella virus vaccine", + "Atopic dermatitis", + "Catheter ablation of tissue of heart" + ), + domain_id = c("Drug", "Condition", "Procedure"), + vocabulary_id = c("CVX", "SNOMED", "SNOMED"), + concept_class_id = c("CVX", "Clinical Finding", "Procedure"), + standard_concept = c("S", "S", "S"), + concept_code = c("21", "24079001", "18286008") +) + +test_that("datatable server works", { + testServer( + mod_datatable_server, + args = list(data = reactiveVal(mock_data)), + { + ns <- session$ns + # Pre-defined golem tests + expect_true(inherits(ns, "function")) + expect_true(grepl(id, ns(""))) + expect_true(grepl("test", ns("test"))) + + out <- session$getReturned() + expect_true(is.reactive(out)) + expect_s3_class(out(), "data.frame") + expect_s3_class(output$datatable, "json") + } + ) +}) + +test_that("module ui works", { + ui <- mod_datatable_ui(id = "test") + golem::expect_shinytaglist(ui) + # Check that formals have not been removed + fmls <- formals(mod_datatable_ui) + for (i in c("id")) { + expect_true(i %in% names(fmls)) + } +}) diff --git a/tests/testthat/test-mod_monthly_count.R b/tests/testthat/test-mod_monthly_count.R new file mode 100644 index 0000000..4b7c7ae --- /dev/null +++ b/tests/testthat/test-mod_monthly_count.R @@ -0,0 +1,73 @@ +mock_monthly_counts <- data.frame( + concept_id = rep(c(40213251, 133834, 4057420), each = 3), + date_year = c(2019L, 2020L, 2020L, 2019L, 2020L, 2020L, 2020L, 2019L, 2019L), + date_month = c(4L, 3L, 5L, 5L, 8L, 4L, 11L, 6L, 3L), + person_count = c(1, 1, 3, 4, 2, 3, 2, 4, 1), + records_per_person = c(1, 1, 1, 1, 1, 1, 1, 1, 1) +) + +# Application-logic tests --------------------------------------------------------------------- + +mock_concept_row <- reactiveVal() + +test_that("mod_monthly_count_server reacts to changes in the selected concept", { + testServer( + mod_monthly_count_server, + # Add here your module params + args = list(data = mock_monthly_counts, selected_concept = mock_concept_row), + { + ns <- session$ns + expect_true(inherits(ns, "function")) + expect_true(grepl(id, ns(""))) + expect_true(grepl("test", ns("test"))) + + selected_row <- list(concept_id = 40213251, concept_name = "test") + mock_concept_row(selected_row) # update reactive value + session$flushReact() + expect_identical(unique(filtered_monthly_counts()$concept_id), selected_row$concept_id) + + selected_row2 <- list(concept_id = 133834, concept_name = "test") + mock_concept_row(selected_row2) # update reactive value + session$flushReact() + expect_identical(unique(filtered_monthly_counts()$concept_id), selected_row2$concept_id) + } + ) +}) + +test_that("mod_monthly_count_server generates an empty plot when no row is selected", { + testServer( + mod_monthly_count_server, + args = list(data = mock_monthly_counts, selected_concept = reactiveVal(NULL)), + { + # When no concept_id is selected, no plot should be rendered + expect_length(output$monthly_count_plot$coordmap$panels[[1]]$mapping, 0) + } + ) +}) + +test_that("module ui works", { + ui <- mod_monthly_count_ui(id = "test") + golem::expect_shinytaglist(ui) + # Check that formals have not been removed + fmls <- formals(mod_monthly_count_ui) + for (i in c("id")) { + expect_true(i %in% names(fmls)) + } +}) + + +# Business-logic tests ------------------------------------------------------------------------ + +test_that("monthly_count_plot correctly parses dates", { + mock_counts <- mock_monthly_counts[mock_monthly_counts$concept_id == 40213251, ] + expected_data <- mock_counts + expected_data$date <- as.Date(paste0( + expected_data$date_year, "-", expected_data$date_month, "-01" + )) + + p <- monthly_count_plot(mock_counts, plot_title = "test") + expect_s3_class(p, "ggplot") + expect_identical(as.data.frame(p$data), expected_data) + expect_false(is.null(p$mapping)) + expect_false(is.null(p$layers)) +}) diff --git a/tests/testthat/test-mod_select_concepts.R b/tests/testthat/test-mod_select_concepts.R new file mode 100644 index 0000000..cec8975 --- /dev/null +++ b/tests/testthat/test-mod_select_concepts.R @@ -0,0 +1,46 @@ +mock_data <- data.frame( + concept_id = c(40213251, 133834, 4057420), + concept_name = c( + "varicella virus vaccine", + "Atopic dermatitis", + "Catheter ablation of tissue of heart" + ), + domain_id = c("Drug", "Condition", "Procedure"), + vocabulary_id = c("CVX", "SNOMED", "SNOMED"), + concept_class_id = c("CVX", "Clinical Finding", "Procedure"), + standard_concept = c("S", "S", "S"), + concept_code = c("21", "24079001", "18286008") +) + +test_that("mod_select_concepts_server reacts to concept selection", { + testServer( + mod_select_concepts_server, + # Add here your module params + args = list(concepts_table = mock_data), + { + ns <- session$ns + # Pre-defined golem tests + expect_true(inherits(ns, "function")) + expect_true(grepl(id, ns(""))) + expect_true(grepl("test", ns("test"))) + + out <- session$getReturned() + select_concepts <- mock_data$concept_name[c(2, 3)] + session$setInputs(select_concepts = select_concepts) + + expect_s3_class(out(), "data.frame") + expect_equal(nrow(out()), 2) + expect_identical(out()$concept_name, select_concepts) + } + ) +}) + +test_that("module ui works", { + ui <- mod_select_concepts_ui(id = "test") + golem::expect_shinytaglist(ui) + # Check that formals have not been removed + fmls <- formals(mod_select_concepts_ui) + for (i in c("id")) { + expect_true(i %in% names(fmls)) + } +}) diff --git a/tests/testthat/test-mod_stat_numeric.R b/tests/testthat/test-mod_stat_numeric.R new file mode 100644 index 0000000..85a6591 --- /dev/null +++ b/tests/testthat/test-mod_stat_numeric.R @@ -0,0 +1,85 @@ +mock_stats <- data.frame( + concept_id = rep(c(40213251, 133834, 4057420), each = 2), + summary_attribute = rep(c("mean", "sd"), times = 3), + value_as_string = rep(NA, 6), + value_as_number = c(1.5, 0.5, 2.5, 0.7, 3.5, 0.8) +) + + +# Application-logic tests --------------------------------------------------------------------- +mock_concept_row <- reactiveVal() + +test_that("mod_stat_numeric_server reacts to changes in the selected concept", { + testServer( + mod_stat_numeric_server, + # Add here your module params + args = list(data = mock_stats, selected_concept = mock_concept_row), + { + ns <- session$ns + expect_true(inherits(ns, "function")) + expect_true(grepl(id, ns(""))) + expect_true(grepl("test", ns("test"))) + + selected_row <- list(concept_id = 40213251, concept_name = "test") + mock_concept_row(selected_row) # update reactive value + session$flushReact() + expect_identical(unique(filtered_summary_stats()$concept_id), selected_row$concept_id) + expect_equal(nrow(filtered_summary_stats()), 2) + + selected_row2 <- list(concept_id = 40213251, concept_name = "test") + mock_concept_row(selected_row2) # update reactive value + session$flushReact() + expect_identical(unique(filtered_summary_stats()$concept_id), selected_row2$concept_id) + expect_equal(nrow(filtered_summary_stats()), 2) + } + ) +}) + +test_that("mod_stat_numeric_server generates an empty plot when no row is selected", { + testServer( + mod_stat_numeric_server, + args = list(data = mock_stats, selected_concept = reactiveVal(NULL)), + { + # When no concept_id is selected, no plot should be rendered + expect_length(output$stat_numeric_plot$coordmap$panels[[1]]$mapping, 0) + } + ) +}) + +test_that("module ui works", { + ui <- mod_stat_numeric_ui(id = "test") + golem::expect_shinytaglist(ui) + # Check that formals have not been removed + fmls <- formals(mod_stat_numeric_ui) + for (i in c("id")) { + expect_true(i %in% names(fmls)) + } +}) + + +# Business-logic tests ------------------------------------------------------------------------ + +test_that("stat_numeric_plot correctly processes data", { + # GIVEN: a data frame with summary statistics that still needs to be processed before plotting + # WHEN: stat_numeric_plot is called with this data + # THEN: the data is first processed correctly and a plot is generated without errors + mock_stats <- mock_stats[mock_stats$concept_id == 40213251, ] + expected_data <- data.frame(concept_id = 40213251, mean = 1.5, sd = 0.5) + + p <- stat_numeric_plot(mock_stats, plot_title = "test") + expect_identical(as.data.frame(p$data), expected_data) +}) + +test_that("stat_numeric_plot only works for a single concept", { + # GIVEN: a data frame with summary statistics for multiple concepts + # WHEN: stat_numeric_plot is called with this data + # THEN: an error is thrown because the function only works for a single concept + mock_stats <- data.frame( + concept_id = rep(c(40213251, 40213252), each = 2), + summary_attribute = c("mean", "sd", "mean", "sd"), + value_as_string = c(NA, NA, NA, NA), + value_as_number = c(1.5, 0.5, 2.5, 0.7) + ) + + expect_error(stat_numeric_plot(mock_stats, plot_title = "test"), "Expecting a single concept ID") +})