diff --git a/DESCRIPTION b/DESCRIPTION index 74bc19ca5..a457258b1 100644 --- a/DESCRIPTION +++ b/DESCRIPTION @@ -1,6 +1,6 @@ Package: aNCA Title: (Pre-)Clinical NCA in a Dynamic Shiny App -Version: 0.1.0.9186 +Version: 0.1.0.9187 Authors@R: c( person("Ercan", "Suekuer", email = "ercan.suekuer@roche.com", role = "aut", comment = c(ORCID = "0009-0001-1626-1526")), diff --git a/NEWS.md b/NEWS.md index 521d7fc5f..682b63476 100644 --- a/NEWS.md +++ b/NEWS.md @@ -31,6 +31,14 @@ * Summary tables can filter which stratification values appear: a "Parameters to show" filter on the `pkpt03/07/08` tables and a "Timepoints to show" filter on the `pkct01` tables restrict the rows to the chosen `PARAM`/timepoint values (#1356) * Summary tables now warn (instead of silently degrading) when a chosen stratification variable is not present in the data — e.g. the "by Dose" concentration tables when a dose-amount column is not carried in the concentration data — so it is clear why a table grouped by fewer variables (#1356) +### TLG Order & Selection +* Simplify the TLG Order Details table: the internal `Condition` column is hidden (it stays in `tlg.yaml` as metadata that still auto-selects urine outputs) and the table is trimmed to Type, Dataset, Output, Footnote, Stratification, and Comment (#1335) +* Urine TLG functions filter to urine specimens internally; when `PCSPEC`/`PPSPEC` is missing, the resulting warning is surfaced as an in-app notification instead of failing silently (#1335) +* Redesign the "Add TLGs to order" picker as a catalog checklist with dataset tabs (PK Concentrations / PK Parameters), search, CSV/XLSX export, per-column select-all, and a live selection count (#1335) +* In the "Add TLGs to order" picker, searching now respects the active dataset tab: matches in another dataset surface as a count on that tab's badge instead of appearing under the current tab, so switching tabs reveals them (#1335) +* In the "Add TLGs to order" picker, the toolbar **Select all** and **Clear all** now act consistently on the whole order — every output across both dataset tabs (limited to the current search when one is active) — while each column's **Select all** stays scoped to that column (#1335) +* Notify the user when a PK-parameter (ADPP) output is requested before NCA has been run, instead of only showing an empty placeholder (#1335) + ### Settings & Configuration * Settings upload auto-restores the full session: mapping, filters, data processing, tab navigation, and auto-runs NCA if previously run. Incompatible settings degrade gracefully with notifications (#1225) * Settings version control: YAML file stores multiple versions with metadata. Save button in header, version selection on upload, version delete support (#1103) diff --git a/inst/WORDLIST b/inst/WORDLIST index 7fab852e7..32b1ea267 100644 --- a/inst/WORDLIST +++ b/inst/WORDLIST @@ -154,6 +154,7 @@ VSSMDP WTBL WTBLU Walkthrough +XLSX XPT YAML aNCA's diff --git a/inst/shiny/app.R b/inst/shiny/app.R index 37fd78137..23069fbb6 100644 --- a/inst/shiny/app.R +++ b/inst/shiny/app.R @@ -79,6 +79,7 @@ ui <- function() { includeCSS(file.path(assets, "main.css")), includeScript(file.path(assets, "index.js")), + includeScript(file.path(assets, "tlg_add_picker.js")), sidebar = navset_pill_list( id = "page", diff --git a/inst/shiny/functions/tlg_add_picker.R b/inst/shiny/functions/tlg_add_picker.R new file mode 100644 index 000000000..cd37d0e97 --- /dev/null +++ b/inst/shiny/functions/tlg_add_picker.R @@ -0,0 +1,169 @@ +#' Helpers for the "Add TLGs to order" modal (issue #1335). +#' +#' The picker was originally a grouped reactable whose Type/Dataset columns were +#' blank on every selectable row. It is rebuilt as a catalog-style checklist: +#' dataset tabs (PK Concentrations / PK Parameters) over one column per output +#' Type (Tables / Listings / Graphs), with a search + download + select-all +#' toolbar, per-column select-all, and a live count on the confirm button. +#' +#' Client-side behaviour lives in `inst/shiny/www/tlg_add_picker.js` +#' (`window.tlgAdd`); styling lives in +#' `inst/shiny/www/styles/partials/_tlg_add_modal.scss`. These helpers only +#' build the server-side UI and translate the checked rows back to `tlg_order()` +#' ids, keeping `tab_tlg_server()` small enough to stay under the cyclomatic +#' complexity limit. + +# Fixed left-to-right order + icon for the Type columns, and dataset tab order. +.TLG_TYPE_ORDER <- c("Table", "Listing", "Graph") +.TLG_TYPE_ICON <- c(Table = "table", Listing = "list-ul", Graph = "chart-line") +.TLG_DATASET_ORDER <- c("PK Concentrations", "PK Parameters") + +#' Escape a string for safe embedding inside a single-quoted JS literal. +#' @param x Character vector. +#' @returns Character vector wrapped in single quotes with `'` and `\` escaped. +#' @noRd +tlg_js_str <- function(x) paste0("'", gsub("(['\\\\])", "\\\\\\1", x), "'") + +#' Build the "Add TLGs" catalog checklist UI. +#' +#' @param avail Tibble of not-yet-selected TLGs (rows of `tlg_order()` with +#' `Selection == FALSE`); must contain `Type`, `Dataset`, `Description`, +#' `Link`, and `id`. +#' @param ns Namespace function for the calling module (`session$ns`). +#' @returns A list with `ui` (the modal body tag) and `group_ids` (the +#' `checkboxGroupInput` ids created, read back on confirm). +#' @noRd +build_add_checklist <- function(avail, ns) { + present_types <- intersect(.TLG_TYPE_ORDER, unique(avail$Type)) + datasets <- c(intersect(.TLG_DATASET_ORDER, unique(avail$Dataset)), + setdiff(unique(avail$Dataset), .TLG_DATASET_ORDER)) + + pairs <- dplyr::distinct(avail, Type, Dataset) + pairs <- pairs[order(match(pairs$Type, .TLG_TYPE_ORDER), pairs$Dataset), ] + pairs$input_id <- paste0("modal_check_", seq_len(nrow(pairs))) + + spec_icon_html <- as.character(icon("circle-info")) + + # One dataset block for a Type column: tagged with data-dataset so the tab bar + # can show/hide it. The active tab is applied client-side. + build_group_ui <- function(type, dataset, input_id) { + rows <- dplyr::filter(avail, Type == !!type, Dataset == !!dataset) + choice_names <- purrr::map2(rows$Description, rows$Link, function(desc, link) { + spec_link <- if (is.na(link)) "" else paste0( + "", spec_icon_html, "" + ) + HTML(paste0("", htmltools::htmlEscape(desc), "", spec_link)) + }) + div( + class = "tlg-ds", `data-dataset` = dataset, + checkboxGroupInput( + inputId = ns(input_id), + label = NULL, + choiceNames = choice_names, + choiceValues = as.character(rows$id) + ) + ) + } + + # One flex column per Type (plain flex, not the bootstrap grid, whose negative + # row margins would misalign the columns against the toolbar). + type_columns <- purrr::map(present_types, function(tp) { + tp_pairs <- dplyr::rename(pairs[pairs$Type == tp, ], type = Type, dataset = Dataset) + div( + class = "tlg-col", + div( + class = "tlg-col-head", + tags$span( + class = "tlg-col-title", + icon(.TLG_TYPE_ICON[[tp]]), paste0(" ", tp, "s"), + tags$span(sum(avail$Type == tp), class = "tlg-col-count") + ), + tags$button( + type = "button", class = "tlg-col-selall", + onclick = "window.tlgAdd.colSelect(this)", "Select all" + ) + ), + div( + class = "tlg-col-body", + purrr::pmap(tp_pairs, build_group_ui), + div(class = "tlg-col-empty", "None in this view", style = "display: none;") + ) + ) + }) + + # Dataset tab bar; first dataset active by default. data-total feeds the count + # badge (restored when the search box is cleared). + tab_bar <- div( + class = "tlg-tabs", + purrr::imap(datasets, function(ds, i) { + ds_total <- sum(avail$Dataset == ds) + tags$button( + type = "button", + class = paste("tlg-tab", if (i == 1) "active" else ""), + `data-dataset` = ds, `data-total` = ds_total, + onclick = paste0("window.tlgAdd.setTab(", tlg_js_str(ds), ", this)"), + ds, tags$span(ds_total, class = "tlg-tab-count") + ) + }) + ) + + # Per-open initialisation: pick the first dataset tab and render. Kept inline + # (not in tlg_add_picker.js) because it depends on the datasets present now. + init_js <- paste0( + "window.tlgAdd.tab = ", tlg_js_str(datasets[1]), "; ", + "window.tlgAdd.q = ''; window.tlgAdd.render();" + ) + + # Shared left inset so toolbar, tabs, column headers and checkbox rows all line + # up on the same left edge (see --tlg-inset in _tlg_add_modal.scss). + ui <- div( + class = "tlg-add-modal", + div( + class = "tlg-toolbar", + tags$input( + type = "text", class = "form-control tlg-search-input", + placeholder = "Search outputs…", + oninput = "window.tlgAdd.setQuery(this.value)" + ), + tags$button(type = "button", class = "btn btn-sm btn-default", + title = "Select every output in both tabs (matching the search)", + onclick = "window.tlgAdd.selectAll()", "Select all"), + tags$button(type = "button", class = "btn btn-sm btn-default", + title = "Clear every output in both tabs (matching the search)", + onclick = "window.tlgAdd.clearAll()", "Clear all"), + div(class = "tlg-toolbar-sep"), + downloadButton(ns("modal_dl_csv"), "CSV", class = "btn-sm btn-default"), + downloadButton(ns("modal_dl_xlsx"), "XLSX", class = "btn-sm btn-default") + ), + tab_bar, + div(class = "tlg-add-checklist tlg-cols", type_columns), + div(class = "tlg-no-matches", "No outputs match your search.", style = "display: none;"), + tags$script(HTML(init_js)) + ) + + list(ui = ui, group_ids = pairs$input_id) +} + +#' Ids of the rows checked in the add-picker modal, mapped back to `tlg_order()`. +#' +#' @param input The module `input` object. +#' @param group_ids Character vector of `checkboxGroupInput` ids in the modal. +#' @returns Integer vector of checked `tlg_order()` ids (empty if none). +#' @noRd +checked_tlg_ids <- function(input, group_ids) { + as.integer(unlist(lapply(group_ids, function(gid) input[[gid]]))) +} + +#' Available-TLG catalog for the modal's CSV / XLSX download. +#' +#' @param df The available-TLG tibble (`modal_avail()`), or `NULL`. +#' @returns A data frame with `Type`, `Dataset`, `PKid`, `Description`. +#' @noRd +tlg_modal_dl_data <- function(df) { + if (is.null(df) || nrow(df) == 0) { + return(data.frame(Type = character(), Dataset = character(), + PKid = character(), Description = character())) + } + dplyr::select(df, Type, Dataset, PKid, Description) +} diff --git a/inst/shiny/modules/tab_tlg.R b/inst/shiny/modules/tab_tlg.R index 89bcd34e5..10240821a 100644 --- a/inst/shiny/modules/tab_tlg.R +++ b/inst/shiny/modules/tab_tlg.R @@ -91,6 +91,7 @@ tab_tlg_server <- function(id, data, adpp = reactive(NULL)) { ), PKid = .x$pkid, Output = paste0("", .x$description, ""), + Link = if (is.null(.x$link)) NA_character_ else .x$link, Label = .x$label, Description = .x$description, Condition = .x$condition, @@ -122,9 +123,13 @@ tab_tlg_server <- function(id, data, adpp = reactive(NULL)) { tlg_order(new_tlg_order) }) + # Columns shown to the user in the Order Details table. Internal columns + # (PKid, Label, Description, Condition) are kept in tlg_order() but hidden: + # Condition drives urine auto-preselect above, Label titles the nav panels, + # and Description feeds the submit log — none need to be user-facing. displayed_order <- reactive({ dplyr::filter(tlg_order(), Selection) %>% - dplyr::select(-id, -Selection) + dplyr::select(Type, Dataset, Output, Footnote, Stratification, Comment) }) %>% bindEvent(data(), input$confirm_add_tlg, input$remove_tlg) @@ -136,7 +141,7 @@ tab_tlg_server <- function(id, data, adpp = reactive(NULL)) { defaultExpanded = TRUE, wrap = TRUE, selection = "multiple", - editable = c("Footnote", "Stratification", "Condition", "Comment"), + editable = c("Footnote", "Stratification", "Comment"), columns = function(df) { define_cols(df, overrides = list(Output = colDef(html = TRUE))) } @@ -145,51 +150,83 @@ tab_tlg_server <- function(id, data, adpp = reactive(NULL)) { observeEvent(selected_tlg_state()$edit(), { info <- selected_tlg_state()$edit() + # info$column is the display-frame column (reactable.extras reports the + # column name; older versions report a positional index). Resolve to a name + # and only ever write to an editable column, so a stray edit event can never + # overwrite an internal column (id/PKid/Condition/...) of the full frame. + editable_cols <- c("Footnote", "Stratification", "Comment") + col <- if (is.numeric(info$column)) names(displayed_order())[info$column] else info$column + req(col %in% editable_cols) + new_tlg_order <- tlg_order() - new_tlg_order[new_tlg_order$Selection, ][info$row, info$column] <- info$value + new_tlg_order[new_tlg_order$Selection, ][info$row, col] <- info$value tlg_order(new_tlg_order) }) + # Issue #1335: the "Add TLGs" picker is a catalog-style checklist -- dataset + # tabs (PK Concentrations / PK Parameters) over one column per Type (Tables / + # Listings / Graphs), with a search + download + select-all toolbar. The UI + # builder and helpers live in inst/shiny/functions/tlg_add_picker.R; + # client-side behaviour in inst/shiny/www/tlg_add_picker.js (window.tlgAdd); + # styling in inst/shiny/www/styles/partials/_tlg_add_modal.scss. + # modal_group_ids -- checkboxGroupInput ids in the current modal (read on confirm) + # modal_avail -- the available-TLG tibble backing the CSV/XLSX downloads + modal_group_ids <- reactiveVal(character(0)) + modal_avail <- reactiveVal(NULL) + # Show modal when the add_tlg button is pressed observeEvent(input$add_tlg, { + avail <- dplyr::arrange(dplyr::filter(tlg_order(), !Selection), Type, Dataset) + modal_avail(avail) + + body <- if (nrow(avail) == 0) { + modal_group_ids(character(0)) + tags$p("All available TLGs are already in the order.") + } else { + checklist <- build_add_checklist(avail, session$ns) + modal_group_ids(checklist$group_ids) + checklist$ui + } + showModal(modalDialog( title = div( - "Add TLGs to Order", + "Add TLGs to order", js_close_button, style = "position: relative;" ), - reactable_ui(session$ns("modal_tlg_table")), + body, footer = tagList( modalButton("Close"), - actionButton(session$ns("confirm_add_tlg"), "Add TLGs to Order") + uiOutput(session$ns("modal_confirm_ui"), inline = TRUE) ), size = "l" )) }) - modal_tlg_state <- reactable_server( - "modal_tlg_table", - reactive({ - dplyr::filter(tlg_order(), !Selection) %>% - dplyr::select(-id, -Selection, -Footnote, -Stratification, -Condition, -Comment) - }), - download_buttons = c("csv", "xlsx"), - groupBy = c("Type", "Dataset"), - wrap = TRUE, - selection = "multiple", - defaultExpanded = TRUE, - width = "775px", # fit to the modal width - columns = function(df) { - define_cols(df, overrides = list(Output = colDef(html = TRUE))) - } + # Download the available-TLG catalog shown in the modal. + output$modal_dl_csv <- downloadHandler( + filename = function() "available_tlgs.csv", + content = function(file) write.csv(tlg_modal_dl_data(modal_avail()), file, row.names = FALSE) + ) + output$modal_dl_xlsx <- downloadHandler( + filename = function() "available_tlgs.xlsx", + content = function(file) writexl::write_xlsx(tlg_modal_dl_data(modal_avail()), file) ) + # Confirm button with a live count of checked outputs; disabled at zero. + output$modal_confirm_ui <- renderUI({ + n <- length(checked_tlg_ids(input, modal_group_ids())) + label <- if (n == 0) "Add to order" else paste0("Add ", n, " to order") + btn <- actionButton(session$ns("confirm_add_tlg"), label, class = "btn-primary") + if (n == 0) shinyjs::disabled(btn) else btn + }) + # Update the Selection column when the confirm_add_tlg button is pressed observeEvent(input$confirm_add_tlg, { - selected_rows <- modal_tlg_state()$selected - if (length(selected_rows) > 0) { + checked_ids <- checked_tlg_ids(input, modal_group_ids()) + if (length(checked_ids) > 0) { tlg_order_data <- tlg_order() - tlg_order_data$Selection[!tlg_order_data$Selection][selected_rows] <- TRUE + tlg_order_data$Selection[tlg_order_data$id %in% checked_ids] <- TRUE tlg_order(tlg_order_data) } removeModal() @@ -252,6 +289,18 @@ tab_tlg_server <- function(id, data, adpp = reactive(NULL)) { apply_labels(data()$conc$data, type = "ADNCA") }) adpp_data_all <- reactive({ + # A PK-parameter (ADPP) output was requested but NCA has not been run, so + # ADPP is unavailable. Surface it as a toast (Gero, #1335) in addition to + # the inline placeholder, since the empty panel alone reads as a silent + # failure. + if (is.null(adpp())) { + # Fixed id so multiple ADPP panels collapse into one toast rather than + # stacking an identical message per output. + showNotification( + "ADPP data is not available. Run NCA first to view PK parameter outputs.", + type = "warning", duration = 10, id = session$ns("adpp_missing") + ) + } validate(need( !is.null(adpp()), "ADPP data is not available. Run NCA first to view PK parameter outputs." diff --git a/inst/shiny/modules/tab_tlg/tlg_module.R b/inst/shiny/modules/tab_tlg/tlg_module.R index 5d3c478b6..054b352e3 100644 --- a/inst/shiny/modules/tab_tlg/tlg_module.R +++ b/inst/shiny/modules/tab_tlg/tlg_module.R @@ -299,7 +299,19 @@ tlg_module_server <- function(id, data, type, render_list, options = NULL, # nol # the PKNCA/dplyr pipeline strips column `label` attributes, which breaks # the `!COLUMN` label-reference syntax in title/subtitle/footnote/axis # inputs (resolved via parse_annotation). - do.call(render_list, purrr::list_modify(list(data = data()), !!!list_options)) + # + # Surface warnings from the render function (e.g. urine TLGs warning that + # PCSPEC/PPSPEC is absent so no specimen filtering was applied) as app + # notifications, then muffle so rendering continues with the result. + withCallingHandlers( + do.call(render_list, purrr::list_modify(list(data = data()), !!!list_options)), + warning = function(w) { + showNotification( + paste0("Notice: ", conditionMessage(w)), type = "warning", duration = 10 + ) + invokeRestart("muffleWarning") + } + ) }, error = function(e) { log_error("Error in list rendering:") diff --git a/inst/shiny/www/main.css b/inst/shiny/www/main.css index 6c1c76b14..7d924ab72 100644 --- a/inst/shiny/www/main.css +++ b/inst/shiny/www/main.css @@ -1187,3 +1187,200 @@ tbody tr:hover td.param-matrix-row-header { .ratio-adj-factor { flex-shrink: 0; } + +.tlg-add-modal { + --tlg-inset: 0.55em; +} + +.tlg-add-modal .tlg-toolbar { + display: flex; + align-items: center; + gap: 0.5em; + flex-wrap: wrap; + margin: 0 0 1.25em; + padding-left: var(--tlg-inset); +} + +.tlg-add-modal .tlg-search-input { + width: 300px; + max-width: 100%; +} + +.tlg-add-modal .tlg-toolbar-sep { + width: 1px; + align-self: stretch; + background: #e5e5e5; + margin: 0.1em 0.35em; +} + +.tlg-add-modal .tlg-tabs { + display: flex; + gap: 0.25em; + margin: 0 0 1.25em; + padding-left: var(--tlg-inset); + border-bottom: 1px solid #e5e5e5; +} + +.tlg-tab { + background: none; + border: none; + padding: 0.5em 0.9em; + font-weight: 600; + color: #7b8794; + border-bottom: 2px solid transparent; + margin-bottom: -1px; + cursor: pointer; +} + +.tlg-tab:hover { + color: #21201f; +} + +.tlg-tab.active { + color: #007bc2; + border-bottom-color: #007bc2; +} + +.tlg-tab-count { + color: #8a8f98; + font-weight: 600; + font-size: 0.8em; + margin-left: 0.35em; +} + +.tlg-cols { + display: flex; + align-items: flex-start; + gap: 1.5em; +} + +.tlg-cols .tlg-col { + flex: 1 1 0; + min-width: 0; +} + +.tlg-cols .tlg-col + .tlg-col { + border-left: 1px solid #eee; + padding-left: 1.5em; +} + +.tlg-col-body { + max-height: 55vh; + overflow-y: auto; +} + +.tlg-col-empty { + color: #8a8f98; + font-style: italic; + font-size: 0.85em; + padding: 0.6em var(--tlg-inset); +} + +.tlg-add-checklist .tlg-col-head { + display: flex; + align-items: baseline; + margin: 0 0 0.75em; + padding: 0 var(--tlg-inset) 0.4em; + border-bottom: 2px solid #007bc2; +} + +.tlg-add-checklist .tlg-col-title { + flex: 1 1 auto; + font-size: 1em; + font-weight: 700; + color: #21201f; +} + +.tlg-add-checklist .tlg-col-head svg, +.tlg-add-checklist .tlg-col-head .fa { + color: #007bc2; + margin-right: 0.3em; +} + +.tlg-add-checklist .tlg-col-count { + color: #8a8f98; + font-weight: 600; + font-size: 0.82em; + margin-left: 0.25em; +} + +.tlg-add-checklist .tlg-ds { + margin-bottom: 0.5em; +} + +.tlg-add-checklist .checkbox { + margin: 0; + padding: 0.4em var(--tlg-inset); + border-radius: 5px; + transition: background 0.1s; +} + +.tlg-add-checklist .checkbox:hover { + background: #f2f6fb; +} + +.tlg-add-checklist .checkbox:has(input:checked) { + background: #E7ECFA; +} + +.tlg-add-checklist .checkbox label { + display: flex; + align-items: flex-start; + gap: 0.55em; + text-align: left; + font-weight: normal; + line-height: 1.4; + padding-left: 0; + color: #21201f; + cursor: pointer; +} + +.tlg-add-checklist .checkbox label > span { + flex: 1 1 auto; + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 0.5em; +} + +.tlg-add-checklist .checkbox input[type="checkbox"] { + position: static; + margin: 0.22em 0 0; + flex: 0 0 auto; + width: 15px; + height: 15px; + accent-color: #007bc2; + cursor: pointer; +} + +.tlg-add-checklist .tlg-spec { + flex: 0 0 auto; + color: #b0b7bf; + margin-top: 0.1em; +} + +.tlg-add-checklist .tlg-spec:hover { + color: #007bc2; +} + +.tlg-col-selall { + flex: 0 0 auto; + background: none; + border: none; + color: #007bc2; + font-size: 0.76em; + font-weight: 600; + cursor: pointer; + padding: 0; +} + +.tlg-col-selall:hover { + text-decoration: underline; +} + +.tlg-no-matches { + color: #8a8f98; + font-style: italic; + padding: 1em 0.5em; + padding-left: var(--tlg-inset); +} diff --git a/inst/shiny/www/styles/main.scss b/inst/shiny/www/styles/main.scss index 3a05ccbe8..37ab40559 100644 --- a/inst/shiny/www/styles/main.scss +++ b/inst/shiny/www/styles/main.scss @@ -11,4 +11,5 @@ @import "partials/_loading_spinner"; @import "partials/_non_nca_analysis"; @import "partials/_parameter_matrix"; -@import "partials/_ratio_formula"; \ No newline at end of file +@import "partials/_ratio_formula"; +@import "partials/_tlg_add_modal"; \ No newline at end of file diff --git a/inst/shiny/www/styles/partials/_tlg_add_modal.scss b/inst/shiny/www/styles/partials/_tlg_add_modal.scss new file mode 100644 index 000000000..23e3f002e --- /dev/null +++ b/inst/shiny/www/styles/partials/_tlg_add_modal.scss @@ -0,0 +1,201 @@ +// "Add TLGs to order" modal: catalog checklist (dataset tabs over Type columns). +// Brand colors come from styles/modules/_colors.scss tokens; the neutral greys +// are modal-local one-offs with no shared token and are kept as literals. +.tlg-add-modal { + --tlg-inset: 0.55em; + + .tlg-toolbar { + display: flex; + align-items: center; + gap: 0.5em; + flex-wrap: wrap; + margin: 0 0 1.25em; + padding-left: var(--tlg-inset); + } + + .tlg-search-input { + width: 300px; + max-width: 100%; + } + + .tlg-toolbar-sep { + width: 1px; + align-self: stretch; + background: #e5e5e5; + margin: 0.1em 0.35em; + } + + .tlg-tabs { + display: flex; + gap: 0.25em; + margin: 0 0 1.25em; + padding-left: var(--tlg-inset); + border-bottom: 1px solid #e5e5e5; + } +} + +.tlg-tab { + background: none; + border: none; + padding: 0.5em 0.9em; + font-weight: 600; + color: #7b8794; + border-bottom: 2px solid transparent; + margin-bottom: -1px; + cursor: pointer; + + &:hover { + color: $anca-black-2; + } + + &.active { + color: $anca-blue; + border-bottom-color: $anca-blue; + } +} + +.tlg-tab-count { + color: #8a8f98; + font-weight: 600; + font-size: 0.8em; + margin-left: 0.35em; +} + +.tlg-cols { + display: flex; + align-items: flex-start; + gap: 1.5em; + + .tlg-col { + flex: 1 1 0; + min-width: 0; + } + + .tlg-col + .tlg-col { + border-left: 1px solid #eee; + padding-left: 1.5em; + } +} + +.tlg-col-body { + max-height: 55vh; + overflow-y: auto; +} + +.tlg-col-empty { + color: #8a8f98; + font-style: italic; + font-size: 0.85em; + padding: 0.6em var(--tlg-inset); +} + +.tlg-add-checklist { + .tlg-col-head { + display: flex; + align-items: baseline; + margin: 0 0 0.75em; + padding: 0 var(--tlg-inset) 0.4em; + border-bottom: 2px solid $anca-blue; + } + + .tlg-col-title { + flex: 1 1 auto; + font-size: 1em; + font-weight: 700; + color: $anca-black-2; + } + + .tlg-col-head svg, + .tlg-col-head .fa { + color: $anca-blue; + margin-right: 0.3em; + } + + .tlg-col-count { + color: #8a8f98; + font-weight: 600; + font-size: 0.82em; + margin-left: 0.25em; + } + + .tlg-ds { + margin-bottom: 0.5em; + } + + .checkbox { + margin: 0; + padding: 0.4em var(--tlg-inset); + border-radius: 5px; + transition: background 0.1s; + + &:hover { + background: #f2f6fb; + } + + &:has(input:checked) { + background: $anca-blue-light; + } + + label { + display: flex; + align-items: flex-start; + gap: 0.55em; + text-align: left; + font-weight: normal; + line-height: 1.4; + padding-left: 0; + color: $anca-black-2; + cursor: pointer; + } + + label > span { + flex: 1 1 auto; + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 0.5em; + } + + input[type="checkbox"] { + position: static; + margin: 0.22em 0 0; + flex: 0 0 auto; + width: 15px; + height: 15px; + accent-color: $anca-blue; + cursor: pointer; + } + } + + .tlg-spec { + flex: 0 0 auto; + color: #b0b7bf; + margin-top: 0.1em; + + &:hover { + color: $anca-blue; + } + } +} + +.tlg-col-selall { + flex: 0 0 auto; + background: none; + border: none; + color: $anca-blue; + font-size: 0.76em; + font-weight: 600; + cursor: pointer; + padding: 0; + + &:hover { + text-decoration: underline; + } +} + +.tlg-no-matches { + color: #8a8f98; + font-style: italic; + padding: 1em 0.5em; + padding-left: var(--tlg-inset); +} diff --git a/inst/shiny/www/tlg_add_picker.js b/inst/shiny/www/tlg_add_picker.js new file mode 100644 index 000000000..91e4dd13a --- /dev/null +++ b/inst/shiny/www/tlg_add_picker.js @@ -0,0 +1,123 @@ +// Client-side behaviour for the "Add TLGs to order" modal (see +// inst/shiny/functions/tlg_add_picker.R). The modal shows dataset tabs +// (PK Concentrations / PK Parameters) over one column per output Type; this +// object drives search, tab switching, and the select-all helpers. The +// per-open initialisation (active tab + first render) is injected inline by the +// R helper because it depends on the datasets present in the current modal. +window.tlgAdd = { + q: "", + tab: null, + + // Toggle checkboxes within `scope`. `mode` standardises what "all" means so + // the toolbar buttons behave consistently (issue #1335): + // "search" -> every row matching the current search, across BOTH dataset + // tabs. A tab is only a view, so the toolbar Select-all / + // Clear-all act on the whole order; search is an intentional + // filter, so they honour it (rows flagged .tlg-nomatch by + // render() are skipped). + // "visible" -> only rows currently shown (active tab AND search): used by + // the per-column "Select all", the granular scoped tool. + _setChecked: function(scope, checked, mode) { + var groups = {}; + scope.querySelectorAll(".checkbox").forEach(function(row) { + if (mode === "visible" && row.style.display === "none") return; + if (mode === "search" && row.classList.contains("tlg-nomatch")) return; + var cb = row.querySelector("input[type=checkbox]"); + if (!cb) return; + cb.checked = checked; + var g = cb.closest(".shiny-input-checkboxgroup"); + if (g) groups[g.id] = g; + }); + Object.keys(groups).forEach(function(id) { + var inp = groups[id].querySelector("input[type=checkbox]"); + if (inp) inp.dispatchEvent(new Event("change", { bubbles: true })); + }); + }, + + selectAll: function() { + var r = document.querySelector(".tlg-add-checklist"); + if (r) this._setChecked(r, true, "search"); + }, + + clearAll: function() { + var r = document.querySelector(".tlg-add-checklist"); + if (r) this._setChecked(r, false, "search"); + }, + + colSelect: function(btn) { + var c = btn.closest(".tlg-col"); + if (c) this._setChecked(c, true, "visible"); + }, + + setQuery: function(v) { + this.q = v || ""; + this.render(); + }, + + setTab: function(v, btn) { + this.tab = v; + document.querySelectorAll(".tlg-tabs .tlg-tab").forEach(function(b) { + b.classList.remove("active"); + }); + if (btn) btn.classList.add("active"); + this.render(); + }, + + render: function() { + var root = document.querySelector(".tlg-add-checklist"); + if (!root) return; + var q = this.q.trim().toLowerCase(); + var tab = this.tab; + // Per-dataset text-match counts are computed independently of the active + // tab (they drive the tab badges), but only the active tab's matches are + // shown. This way searching in a term that only matches another dataset + // surfaces the count on that tab's badge -- clicking it reveals the match + // -- instead of silently showing another dataset's rows under this tab. + var dsMatch = {}; + var totalMatches = 0; + root.querySelectorAll(".tlg-ds").forEach(function(ds) { + var name = ds.getAttribute("data-dataset"); + var isActive = name === tab; + var anyVisible = false; + ds.querySelectorAll(".checkbox").forEach(function(it) { + var textMatch = q === "" || it.textContent.toLowerCase().indexOf(q) > -1; + // Flag search-match independently of the active tab, so the toolbar + // Select-all / Clear-all ("search" mode) can reach matches on the + // other dataset tab too (issue #1335). + it.classList.toggle("tlg-nomatch", !textMatch); + if (textMatch) { + dsMatch[name] = (dsMatch[name] || 0) + 1; + totalMatches++; + } + var show = isActive && textMatch; + it.style.display = show ? "" : "none"; + if (show) anyVisible = true; + }); + ds.style.display = anyVisible ? "" : "none"; + }); + // Per-column: visible count, empty state, hide select-all when empty. + root.querySelectorAll(".tlg-col").forEach(function(col) { + var vis = 0; + col.querySelectorAll(".checkbox").forEach(function(it) { + if (it.style.display !== "none") vis++; + }); + var cnt = col.querySelector(".tlg-col-count"); + if (cnt) cnt.textContent = vis; + var empty = col.querySelector(".tlg-col-empty"); + if (empty) empty.style.display = vis ? "none" : ""; + var sa = col.querySelector(".tlg-col-selall"); + if (sa) sa.style.display = vis ? "" : "none"; + }); + // Tab badges: per-dataset match count while searching, dataset total otherwise. + document.querySelectorAll(".tlg-tabs .tlg-tab").forEach(function(t) { + var badge = t.querySelector(".tlg-tab-count"); + if (!badge) return; + badge.textContent = q === "" + ? t.getAttribute("data-total") + : (dsMatch[t.getAttribute("data-dataset")] || 0); + }); + // Global "no matches" message only when nothing matches in any dataset. + var nm = document.querySelector(".tlg-no-matches"); + if (nm) nm.style.display = totalMatches === 0 ? "" : "none"; + } +}; diff --git a/tests/testthat/test-tab_tlg.R b/tests/testthat/test-tab_tlg.R index 933e230ba..0d47ec85e 100644 --- a/tests/testthat/test-tab_tlg.R +++ b/tests/testthat/test-tab_tlg.R @@ -1,15 +1,20 @@ -# Boundary behaviour of tab_tlg_server. The TLG modules receive an -# already-exclusion-filtered, label-restored plain data frame; the filtering -# and label restoration happen here (not inside tlg_module_server). +# Server-side tests for tab_tlg_server: +# - add-picker selection, removal, and the Order Details edit write-back (issue #1335) +# - data boundary: the TLG modules receive an already-exclusion-filtered, +# label-restored plain data frame; the filtering and label restoration happen +# here, not inside tlg_module_server (issue #1336 / #1356). # Source the tab_tlg module and its server-side dependencies. local({ library(shiny) + library(dplyr) + library(purrr) library(logger) library(reactable) - library(dplyr) + library(reactable.extras) shiny_dir <- system.file("shiny", package = "aNCA") for (f in list( + c("functions", "tlg_add_picker.R"), c("modules", "tab_tlg", "tlg_module.R"), c("modules", "tab_tlg", "tlg_option_select.R"), c("modules", "common", "reactable.R"), @@ -20,6 +25,79 @@ local({ }, envir = parent.env(environment())) +test_data <- reactive(list(conc = list(data = data.frame( + USUBJID = c("S1", "S2"), + PCSPEC = c("PLASMA", "PLASMA"), + AVAL = c(1, 2), + stringsAsFactors = FALSE +)))) + +describe("tab_tlg_server: add-picker selection", { + it("sets Selection = TRUE for exactly the checked ids on confirm", { + testServer(tab_tlg_server, args = list(data = test_data), { + session$flushReact() + target <- head(tlg_order()$id[!tlg_order()$Selection], 2) + expect_length(target, 2) + + # The confirm handler reads modal_group_ids() and input[[gid]]; drive it + # directly (values not belonging to a real group are simply ignored by the + # id %in% checked_ids mapping). + modal_group_ids("grp") + session$setInputs(grp = as.character(target)) + session$setInputs(confirm_add_tlg = 1) + session$flushReact() + + expect_true(all(tlg_order()$Selection[tlg_order()$id %in% target])) + }) + }) + + it("leaves Selection unchanged when nothing is checked", { + testServer(tab_tlg_server, args = list(data = test_data), { + session$flushReact() + before <- tlg_order()$Selection + modal_group_ids("grp") + session$setInputs(grp = character(0)) + session$setInputs(confirm_add_tlg = 1) + session$flushReact() + expect_identical(tlg_order()$Selection, before) + }) + }) +}) + +describe("tab_tlg_server: Order Details edit write-back", { + it("writes an edited Footnote into the matching full-frame row", { + testServer(tab_tlg_server, args = list(data = test_data), { + session$flushReact() + # First selected (displayed) row maps to the first Selection == TRUE row. + first_id <- tlg_order()$id[tlg_order()$Selection][1] + + # selected_tlg_state()$edit() is fed by the nested reactable module's + # edit_ input; set it through the namespaced id and clear the debounce. + session$setInputs( + `selected_tlg_table-edit_Footnote` = list(row = 1, column = "Footnote", value = "My note") + ) + session$elapse(800) + session$flushReact() + + row <- tlg_order()[tlg_order()$id == first_id, ] + expect_equal(row$Footnote, "My note") + }) + }) + + it("ignores an edit targeting a non-editable column", { + testServer(tab_tlg_server, args = list(data = test_data), { + session$flushReact() + before <- tlg_order() + session$setInputs( + `selected_tlg_table-edit_Footnote` = list(row = 1, column = "Type", value = "HACKED") + ) + session$elapse(800) + session$flushReact() + expect_identical(tlg_order()$Type, before$Type) + }) + }) +}) + describe("tab_tlg_server: data boundary", { adnca_df <- data.frame( USUBJID = c("S1", "S2"), AVAL = c(1, 2), diff --git a/tests/testthat/test-tlg_add_picker.R b/tests/testthat/test-tlg_add_picker.R new file mode 100644 index 000000000..1a004d89f --- /dev/null +++ b/tests/testthat/test-tlg_add_picker.R @@ -0,0 +1,67 @@ +# Tests for the "Add TLGs to order" modal helpers (issue #1335). +local({ + library(shiny) + shiny_dir <- system.file("shiny", package = "aNCA") + source(file.path(shiny_dir, "functions", "tlg_add_picker.R"), local = TRUE) +}, +envir = parent.env(environment())) + +avail_fixture <- function() { + dplyr::tibble( + id = c(2L, 5L, 9L), + Type = c("Table", "Listing", "Graph"), + Dataset = c("PK Concentrations", "PK Parameters", "PK Concentrations"), + PKid = c("pkct01", "pkcl01", "pkpg01"), + Description = c("Conc summary table", "Listing of parameters", "Cumulative plot"), + Link = c("http://spec/1", NA_character_, "http://spec/3") + ) +} + +describe("checked_tlg_ids", { + it("returns integer ids from the checked group inputs", { + input <- list(g1 = c("2", "9"), g2 = character(0)) + expect_identical(checked_tlg_ids(input, c("g1", "g2")), c(2L, 9L)) + }) + + it("returns an empty integer vector when nothing is checked", { + expect_identical(checked_tlg_ids(list(g1 = NULL), "g1"), integer(0)) + }) +}) + +describe("tlg_modal_dl_data", { + it("selects the catalog columns", { + out <- tlg_modal_dl_data(avail_fixture()) + expect_equal(names(out), c("Type", "Dataset", "PKid", "Description")) + expect_equal(nrow(out), 3) + }) + + it("returns an empty typed frame for NULL or empty input", { + for (df in list(NULL, avail_fixture()[0, ])) { + empty <- tlg_modal_dl_data(df) + expect_equal(names(empty), c("Type", "Dataset", "PKid", "Description")) + expect_equal(nrow(empty), 0) + } + }) +}) + +describe("build_add_checklist", { + it("creates one checkbox group per Type/Dataset pair and returns their ids", { + res <- build_add_checklist(avail_fixture(), ns = identity) + + # 3 distinct Type/Dataset pairs -> 3 checkbox groups + expect_length(res$group_ids, 3) + expect_true(all(grepl("^modal_check_", res$group_ids))) + + html <- as.character(res$ui) + # every output description is rendered + expect_true(grepl("Conc summary table", html)) + expect_true(grepl("Listing of parameters", html)) + expect_true(grepl("Cumulative plot", html)) + # dataset tab bar + catalog checklist scaffolding present + expect_true(grepl("tlg-tabs", html)) + expect_true(grepl("tlg-add-checklist", html)) + # spec link only where Link is non-NA + expect_true(grepl("http://spec/1", html, fixed = TRUE)) + expect_true(grepl("http://spec/3", html, fixed = TRUE)) + }) +}) diff --git a/tests/testthat/test-tlg_module.R b/tests/testthat/test-tlg_module.R index 474ed92c9..5ee8352a7 100644 --- a/tests/testthat/test-tlg_module.R +++ b/tests/testthat/test-tlg_module.R @@ -432,3 +432,48 @@ describe("tlg_module_server", { ) }) }) + +# --------------------------------------------------------------------------- +# tlg_module_server: render-warning surfacing (issue #1335) +# --------------------------------------------------------------------------- + +describe("tlg_module_server: warning surfacing", { + test_data <- shiny::reactive( + list(conc = list(data = data.frame( + NFRLT = 1:3, AVAL = c(5, 4, 3), stringsAsFactors = FALSE + ))) + ) + # Emits a warning (e.g. a urine TLG reporting PCSPEC/PPSPEC is absent) but + # still returns a result. + render_list_warns <- function(data, ...) { + warning("PCSPEC/PPSPEC not found; specimen filtering skipped") + list("plot_a") + } + + it("muffles render warnings, notifies with a 'Notice:' prefix, and continues", { + notes <- character(0) + mockery::stub( + tlg_module_server, "showNotification", + function(ui, ...) notes[[length(notes) + 1]] <<- ui + ) + shiny::testServer( + tlg_module_server, + args = list( + data = test_data, + type = "graph", + render_list = render_list_warns, + options = list() + ), + { + session$setInputs(entries_per_page = "All") + session$elapse(800) # clear the debounce(750) + # Muffled: rendering continued and produced the returned list. + expect_equal(tlg_list(), list("plot_a")) + } + ) + # The warning was surfaced to the user with the friendlier prefix. + expect_length(notes, 1) + expect_match(notes[[1]], "^Notice: ") + expect_match(notes[[1]], "specimen filtering skipped") + }) +})