diff --git a/NAMESPACE b/NAMESPACE index c7908a7..bb9f54c 100644 --- a/NAMESPACE +++ b/NAMESPACE @@ -33,6 +33,7 @@ export(visualise_project) export(wrap_string) import(assertthat) import(dplyr) +importFrom(bslib,bs_theme) importFrom(covr,file_coverage) importFrom(data.table,rbindlist) importFrom(dplyr,group_by) @@ -45,6 +46,7 @@ importFrom(flextable,flextable) importFrom(flextable,width) importFrom(here,here) importFrom(htmltools,a) +importFrom(htmltools,htmlDependency) importFrom(igraph,V) importFrom(igraph,degree) importFrom(igraph,graph_from_data_frame) @@ -54,6 +56,15 @@ importFrom(officer,body_add_par) importFrom(officer,read_docx) importFrom(roxygen2,parse_file) importFrom(rstudioapi,navigateToFile) +importFrom(shiny,br) +importFrom(shiny,div) +importFrom(shiny,icon) +importFrom(shiny,span) +importFrom(shiny,tags) +importFrom(shinyWidgets,show_alert) +importFrom(shinyWidgets,show_toast) +importFrom(shinyjs,onclick) +importFrom(shinyjs,useShinyjs) importFrom(stats,na.omit) importFrom(stringr,str_locate_all) importFrom(stringr,str_replace) @@ -64,3 +75,4 @@ importFrom(utils,getParseData) importFrom(visNetwork,visEdges) importFrom(visNetwork,visNetwork) importFrom(visNetwork,visOptions) +importFrom(waiter,use_waiter) diff --git a/R/app_server.R b/R/app_server.R new file mode 100644 index 0000000..83cecf8 --- /dev/null +++ b/R/app_server.R @@ -0,0 +1,420 @@ +#' Help text +#' @importFrom shiny div icon span br +#' @return A shiny HTML element +help_text <- div( + class = "subtitle-container", + span( + class = "subtitle-text", + "Hover over nodes for more information.", br(), + "Functions without a test are ", span(class="text-danger fw-bolder", "red"), + "and those with a test are ", span(class="text-success fw-bolder", "green"), ". ", + br(), + "Click on", icon( + name = "robot", + style = "color: #337ab7; margin-right: 5px; margin-left: 5px;" + ), + " to request an AI generated summary of the corresponding function.", br(), + icon( + name = "up-right-from-square", + style = "color: #75AADB; margin-right: 5px; margin-left: 5px;" + ), + " to open the file in RStudio, or", br(), + icon( + name = "eye", + style = "color: #337ab7; margin-right: 5px; margin-left: 5px;" + ), + " to request a visualisation of the function's call graph." + ) +) + +#' Remove artefacts from file path +#' +#' @param file_location Character scalar specifying the path of a file. +#' @param project_path Character scalar specifying the path of the project. +#' +#' @return A character scalar +#' +#' @importFrom here here +#' +#' @examples +#' \dontrun{ +#' cleaned_file_path <- get_function_path( +#' file_location = "tests/testthat/example_project/R/calculate_QALYs.R#L41" +#' ) +#' cleaned_file_path <- get_function_path( +#' file_location = c( +#' "tests/testthat/example_project/R/calculate_QALYs.R#L41", +#' "tests/testthat/example_project/R/calculate_QALYs.R#L49" +#' ) +#' ) +#' } +get_function_path <- function(file_location, project_path) { + + get_function_path <- gsub("#.*", "", file_location) + + full_file_path <- ifelse( + test = is.na(get_function_path), + yes = "", + no = paste0(project_path, "/", get_function_path) + ) + + return(full_file_path) +} + +#' Extract function line in file path +#' +#' @param file_location Character scalar specifying the path of a file. +#' +#' @return A numeric scalar +#' +#' @examples +#' \dontrun{ +#' cleaned_function_line <- get_function_line( +#' file_location = "tests/testthat/example_project/R/calculate_QALYs.R:L41" +#' ) +#' cleaned_function_line <- get_function_line( +#' file_location = c( +#' "tests/testthat/example_project/R/calculate_QALYs.R#L41", +#' "tests/testthat/example_project/R/calculate_QALYs.R#L49" +#' ) +#' ) +#' } +get_function_line <- function(file_location) { + + function_line <- gsub(".*#L", "", file_location) + + function_line <- ifelse( + test = is.na(function_line), + yes = -1L, + no = as.numeric(function_line) + ) + + return(function_line) +} + +#' Create closable shiny tab +#' +#' @param tab_name Character scalar representing the name or title of the shiny +#' tab. +#' @param content_output_Id Character scalar representing the id of the shiny +#' tab. +#' @param output_type Character scalar specifying the type of rendered output. +#' Default is `"text"` and can also accept `"HTML"`. +#' +#' @return A tab that can be passed to `shiny::tabsetPanel()` +make_closable_tab <- function( + tab_name, + content_output_Id, + output_type = "text") { + shiny::tabPanel( + title = shiny::div( + style = "display: flex; justify-content: space-between; align-items: center;", + shiny::span( + shiny::HTML( + paste0("", tab_name, "") + ), + style = "flex-grow: 1;" + ), + shiny::actionButton( + inputId = "close", + label = shiny::HTML( + '' + ), + class = "close-tab", + onclick = sprintf( + "Shiny.setInputValue('close_tab', '%s');", + content_output_Id + ) + ) + ), + shiny::div( + class = "custom-tab-content", + if(output_type != "HTML") { + shiny::verbatimTextOutput(outputId = content_output_Id) + } else { + shiny::htmlOutput(outputId = content_output_Id) + } + ), + value = content_output_Id + ) +} + +#' Create Shiny app server logic +#' +#' @inheritParams run_shiny_app +#' @param foo_path path to the function folder +#' @importFrom shinyWidgets show_toast show_alert +#' @importFrom shinyjs onclick +#' +#' @return Shiny app server logic +app_server <- function(network_object, project_path, foo_path) { + function(input, output, session) { + + # Create a new environment to avoid sourcing scripts into the namespace + pkg_env <- new.env(parent = baseenv()) + + # Load all functions into this environment + load_functions_into_env(path = foo_path, env = pkg_env) + + # Keep track of the current tab's output ID + currentTabId <- shiny::reactiveVal(NULL) + + # Keep track of the number of AI-generated summary requests + aiAssit_calls <- shiny::reactiveVal(0) + + # Render the network visual + output$networkPlot <- visNetwork::renderVisNetwork({ + network_object + }) + + onclick("question-icon-click", { + show_toast( + title = "Help", + text = help_text, + type = "info", + timer = 0, + width = "500px" + ) + }) + + # Observer to handle AI response in a new tab within the shiny app + shiny::observeEvent( + ignoreNULL = TRUE, + ignoreInit = TRUE, + eventExpr = input$aiAssist, + handlerExpr = { + # If there's a current tab open, remove it + if (!is.null(currentTabId())) { + shiny::removeTab( + inputId = "fileTabs", + target = currentTabId() + ) + } + + if(input$aiAssist != "") { + LLM_API_URL <- Sys.getenv("LLM_API_URL") + LLM_API_KEY <- Sys.getenv("LLM_API_KEY") + if (LLM_API_URL == "" || LLM_API_KEY == "") { + show_alert( + title = "LLM API not set", + text = "LLM_API_URL and LLM_API_KEY must be set. See README for more details.", + type = "error", + html = FALSE, + closeOnClickOutside = TRUE, + showCloseButton = FALSE, + session = shiny::getDefaultReactiveDomain() + ) + return() + } + function_name <- input$aiAssist + tab_name <- paste(function_name) + + # Check if the function name refers to an existing function + if (is.function(get(x = function_name, envir = pkg_env))) { + # Check if the number of calls did not exceed a maximum: + if(aiAssit_calls() < 5) { + # Waiter + waiter <- waiter::Waiter$new( + html = shiny::div( + style = "display: flex; + flex-direction: column; + align-items: center; + justify-content:center; + color: white; + opacity: 1 !important;", + shiny::h4("Please wait ..."), + shiny::h6(paste( + "generating", function_name, "function summary ..." + )), + shiny::br(), + shiny::br(), + waiter::spin_wandering_cubes() + ), + hide_on_render = FALSE + ) + waiter$show() + on.exit(waiter$hide()) + + # Set the ID of the new tab to be the current one + currentTabId(tab_name) + + # Query AI: + ai_response <- summarise_function_with_LLM( + foo_name = function_name, + llm_api_url = Sys.getenv("LLM_API_URL"), + llm_api_key = Sys.getenv("LLM_API_KEY"), + envir = pkg_env + ) + + # Create the new tab and output its content + output[[tab_name]] <- shiny::renderUI({ + shiny::HTML(ai_response) + }) + + # Update the number of calls + aiAssit_calls(aiAssit_calls() + 1) + + # Dynamically adjust column widths + shinyjs::runjs( + '$("#mainColumn").removeClass("col-sm-11").addClass("col-sm-6");' + ) + shinyjs::runjs( + '$("#tabColumn").removeClass("col-sm-1").addClass("col-sm-6");' + ) + + # Insert the new tab and set it to the current + shiny::insertTab( + inputId = "fileTabs", + make_closable_tab( + tab_name = paste("AI summary -", tab_name), + content_output_Id = tab_name, + output_type = "HTML" + ), + select = TRUE + ) + + } else { + # Reset the current tab ID + currentTabId(NULL) + # Reset columns width + shinyjs::runjs( + '$("#mainColumn").removeClass("col-sm-6").addClass("col-sm-11");' + ) + shinyjs::runjs( + '$("#tabColumn").removeClass("col-sm-6").addClass("col-sm-1");' + ) + # Notify the user if the file is not found + shiny::showNotification( + ui = "You have exceeded the allotted AI queries.", + type = "warning" + ) + } + } else { + # Notify the user if the file is not found + shiny::showNotification( + ui = paste("Function not found:", function_name), + type = "error" + ) + } + } + } + ) + + # Observer to handle opening files in RStudio + shiny::observeEvent( + ignoreNULL = TRUE, + ignoreInit = TRUE, + eventExpr = input$openInRStudio, + handlerExpr = { + # Open the file in RStudio + file_location <- input$openInRStudio + file_path <- get_function_path( + file_location = file_location, + project_path = project_path + ) + function_line <- get_function_line( + file_location = file_location + ) + + if (file.exists(file_path)) { + rstudioapi::navigateToFile( + file = file_path, + line = function_line + ) + } else { + shiny::showNotification( + paste("File not found:", file_path), + type = "error" + ) + } + } + ) + + # Observer to handle opening files in a new tab within the shiny app + shiny::observeEvent( + ignoreNULL = TRUE, + ignoreInit = TRUE, + eventExpr = input$openInShiny, + handlerExpr = { + if(input$openInShiny != "") { + file_location <- input$openInShiny + file_location <- get_function_path( + file_location = file_location, + project_path = project_path + ) + tab_name <- basename(file_location) + + # If there's a current tab open, remove it + if (!is.null(currentTabId())) { + shiny::removeTab( + inputId = "fileTabs", + target = currentTabId() + ) + } + + # Set the ID of the new tab to be the current one + currentTabId(tab_name) + + # Check if the file path is valid + if (file.exists(file_location)) { + # Read the file content + file_content <- readLines(file_location) + + # Create the new tab and output its content + output[[tab_name]] <- shiny::renderPrint({ + cat(paste(file_content, collapse = "\n")) + }) + + # Dynamically adjust column widths + shinyjs::runjs( + '$("#mainColumn").removeClass("col-sm-11").addClass("col-sm-6");' + ) + shinyjs::runjs( + '$("#tabColumn").removeClass("col-sm-1").addClass("col-sm-6");' + ) + + # Insert the new tab and set it to the current + shiny::insertTab( + inputId = "fileTabs", + make_closable_tab( + tab_name = tab_name, + content_output_Id = tab_name + ), + select = TRUE + ) + } else { + # Notify the user if the file is not found + shiny::showNotification( + ui = paste("File not found:", file_location), + type = "error" + ) + } + } + } + ) + + # Observer to remove the tab opened in the shiny app + shiny::observeEvent( + ignoreNULL = TRUE, + ignoreInit = TRUE, + eventExpr = input$close_tab, + handlerExpr = { + if (!is.null(currentTabId())) { + shiny::removeTab( + inputId = "fileTabs", + target = currentTabId() + ) + # Reset the current tab ID + currentTabId(NULL) + # Reset columns width + shinyjs::runjs( + '$("#mainColumn").removeClass("col-sm-6").addClass("col-sm-11");' + ) + shinyjs::runjs( + '$("#tabColumn").removeClass("col-sm-6").addClass("col-sm-1");' + ) + } + } + ) + } +} diff --git a/R/app_ui.R b/R/app_ui.R new file mode 100644 index 0000000..5da2e1d --- /dev/null +++ b/R/app_ui.R @@ -0,0 +1,92 @@ +#' Define head tags for the Shiny app +#' +#' @importFrom htmltools htmlDependency +#' @importFrom shiny tags +#' @importFrom shinyjs useShinyjs +#' @importFrom waiter use_waiter +#' @return Head tags for the Shiny app +app_ui_head_tags <- function() { + tags$head( + tags$link( + rel = "stylesheet", + type = "text/css", + href = "https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0-beta3/css/all.min.css" + ), + htmlDependency( + name = "custom_css", + version = "0.0.1", + src = system.file("assets", package = "assertHE"), + stylesheet = "custom_css.css" + ), + htmlDependency( + name = "custom_js", + version = "0.0.1", + src = system.file("assets", package = "assertHE"), + script = "custom_js.js" + ), + useShinyjs(), + use_waiter() + ) +} + +#' Create Shiny app UI +#' +#' @param network_title Character string representing the title of the network to be displayed above the network. +#' @importFrom bslib bs_theme +#' @return Shiny app user interface +app_ui <- function(network_title) { + # Use bslib theme with flatly preset + theme <- bs_theme(version = 5, bootswatch = "flatly") + + bslib::page_fluid( + class = "p-3", + title = network_title, + theme = theme, + app_ui_head_tags(), + # Define network plot title/subtitle divs + div( + class = "title-container", + span( + class = "title-text", + network_title + ), + span( + id = "question-icon-click", + class = "help-icon-container", + icon( + class = "help-icon text-muted", + name = "question-circle" + ) + ) + ), + # Define main panel + shiny::fluidRow( + shiny::column( + # Prevent another tabs from covering network tooltip/popup + style = "z-index: 10000;", + width = 11, + visNetwork::visNetworkOutput( + outputId = "networkPlot" + ), + id = "mainColumn" + ), + shiny::column( + width = 1, + # Define a tabsetPanel to contain the dynamic tabs showing user code + shiny::tabsetPanel( + id = "fileTabs" + ), + id = "tabColumn" + ) + ), + shiny::HTML( + paste0( + '
' + ) + ) + ) +} diff --git a/R/project_visualiser.R b/R/project_visualiser.R index 8ede364..f489b3d 100644 --- a/R/project_visualiser.R +++ b/R/project_visualiser.R @@ -621,8 +621,6 @@ plotNetwork <- function(df_edges, return(g) } - - #' Process Nodes #' #' Process unique nodes from a dataframe of edges. @@ -652,592 +650,6 @@ processNodes <- function(df_edges, return(df_nodes) } -#' Remove artefacts from file path -#' -#' @param file_location Character scalar specifying the path of a file. -#' @param project_path Character scalar specifying the path of the project. -#' -#' @return A character scalar -#' -#' @importFrom here here -#' -#' @examples -#' \dontrun{ -#' cleaned_file_path <- get_function_path( -#' file_location = "tests/testthat/example_project/R/calculate_QALYs.R#L41" -#' ) -#' cleaned_file_path <- get_function_path( -#' file_location = c( -#' "tests/testthat/example_project/R/calculate_QALYs.R#L41", -#' "tests/testthat/example_project/R/calculate_QALYs.R#L49" -#' ) -#' ) -#' } -get_function_path <- function(file_location, project_path) { - - get_function_path <- gsub("#.*", "", file_location) - - full_file_path <- ifelse( - test = is.na(get_function_path), - yes = "", - no = paste0(project_path, "/", get_function_path) - ) - - return(full_file_path) -} - -#' Extract function line in file path -#' -#' @param file_location Character scalar specifying the path of a file. -#' -#' @return A numeric scalar -#' -#' @examples -#' \dontrun{ -#' cleaned_function_line <- get_function_line( -#' file_location = "tests/testthat/example_project/R/calculate_QALYs.R:L41" -#' ) -#' cleaned_function_line <- get_function_line( -#' file_location = c( -#' "tests/testthat/example_project/R/calculate_QALYs.R#L41", -#' "tests/testthat/example_project/R/calculate_QALYs.R#L49" -#' ) -#' ) -#' } -get_function_line <- function(file_location) { - - function_line <- gsub(".*#L", "", file_location) - - function_line <- ifelse( - test = is.na(function_line), - yes = -1L, - no = as.numeric(function_line) - ) - - return(function_line) -} - - -#' Create closable shiny tab -#' -#' @param tab_name Character scalar representing the name or title of the shiny -#' tab. -#' @param content_output_Id Character scalar representing the id of the shiny -#' tab. -#' @param output_type Character scalar specifying the type of rendered output. -#' Default is `"text"` and can also accept `"HTML"`. -#' -#' @return A tab that can be passed to `shiny::tabsetPanel()` -make_closable_tab <- function( - tab_name, - content_output_Id, - output_type = "text") { - shiny::tabPanel( - title = shiny::div( - style = "display: flex; justify-content: space-between; align-items: center;", - shiny::span( - shiny::HTML( - paste0("", tab_name, "") - ), - style = "flex-grow: 1;" - ), - shiny::actionButton( - inputId = "close", - label = shiny::HTML( - '' - ), - class = "close-tab", - onclick = sprintf( - "Shiny.setInputValue('close_tab', '%s');", - content_output_Id - ) - ) - ), - shiny::div( - class = "custom-tab-content", - if(output_type != "HTML") { - shiny::verbatimTextOutput(outputId = content_output_Id) - } else { - shiny::htmlOutput(outputId = content_output_Id) - } - ), - value = content_output_Id - ) -} - -#' Create Shiny app UI -#' -#' @param network_title Character string representing the title of the network to be displayed above the network. -#' -#' @return Shiny app user interface -define_app_ui <- function(network_title) { - - shiny::fluidPage( - # Initialize shinyjs and waiter - shinyjs::useShinyjs(), - waiter::use_waiter(), - # Define javaScript functions - shiny::tags$head( - shiny::tags$link( - rel = "stylesheet", - type = "text/css", - href = "https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0-beta3/css/all.min.css" - ), - shiny::tags$script(" - // Initialize a variable to mirror the values of 'aiAssist', - // 'openInShiny' and 'openInRStudio' - var currentAiAssistValue = null; - var currentOpenInShinyValue = null; - var currentOpenInRstudioValue = null; - - function aiAssist(function_name) { - console.log('Executing JavaScript function aiAssist'); - console.log('Function name: ' + function_name); - - // Check if the current value is the same as 'function_name' - if (currentAiAssistValue === function_name) { - - // Temporarily set 'aiAssist' and 'close_tab' to null - // to ensure the change is detected - Shiny.setInputValue('aiAssist', null, {priority: 'event'}); - Shiny.setInputValue('close_tab', null, {priority: 'event'}); - - // Use setTimeout to ensure the null value is processed before setting - // the new value - setTimeout(function() { - Shiny.setInputValue('aiAssist', function_name, {priority: 'event'}); - }, 10); - - } else { - - // Directly set 'aiAssist' to 'function_name' - Shiny.setInputValue('aiAssist', function_name); - } - - // Update the mirror variable to reflect the new value - currentAiAssistValue = function_name; - } - - function openInShiny(file_location) { - console.log('Executing JavaScript function openInShiny'); - console.log('File location: ' + file_location); - - // Check if the current value is the same as 'file_location' - if (currentOpenInShinyValue === file_location) { - - // Temporarily set 'openInShiny' and 'close_tab' to null - // to ensure the change is detected - Shiny.setInputValue('openInShiny', null, {priority: 'event'}); - Shiny.setInputValue('close_tab', null, {priority: 'event'}); - - // Use setTimeout to ensure the null value is processed before setting - // the new value - setTimeout(function() { - Shiny.setInputValue('openInShiny', file_location, {priority: 'event'}); - }, 10); - } else { - - // Directly set 'openInShiny' to 'file_location' if the values are - // different - Shiny.setInputValue('openInShiny', file_location, {priority: 'event'}); - } - - // Update the mirror variable to reflect the new value - currentOpenInShinyValue = file_location; - } - - function openInRStudio(file_location) { - console.log('Executing JavaScript function openInRStudio'); - console.log('File location: ' + file_location); - - // Check if the current value is the same as 'file_location' - if (currentOpenInRstudioValue === file_location) { - - // Temporarily set 'openInRStudio' to null to ensure the change is - // detected - Shiny.setInputValue('openInRStudio', null, {priority: 'event'}); - - // Use setTimeout to ensure the null value is processed before setting - // the new value - setTimeout(function() { - Shiny.setInputValue('openInRStudio', file_location, {priority: 'event'}); - }, 10); - - } else { - - // Directly set 'openInRStudio' to 'file_location' - Shiny.setInputValue('openInRStudio', file_location); - } - - // Update the mirror variable to reflect the new value - currentOpenInRstudioValue = file_location; - } - - $(document).on('shiny:connected', function(event) { - function adjustTabHeight() { - var windowHeight = $(window).height(); - // Distance from the top of the viewport - var offsetTop = $('#fileTabs').offset().top; - // Subtract any additional margin or padding - var tabHeight = windowHeight - offsetTop - 20; - $('.tab-content').css('height', tabHeight + 'px'); - } - - // Adjust the height on window resize - $(window).resize(adjustTabHeight); - - // Initial adjustment - adjustTabHeight(); - }); - "), - # Define CSS - shiny::tags$style(" - /* Make tab content scrollable */ - .tab-content { - /* Enable vertical scrolling */ - overflow-y: auto; - } - - /* Style for custom tab content background */ - .custom-tab-content { - background-color: #f5f5f5; - padding-bottom: 5px; - border-radius: 4px; - margin-bottom: 20px; - } - - /* Reduce padding around close actionButton */ - .close-tab { - border: none; - padding: 5px; - margin: 5px; - color: red; - } - - /* Waiter */ - .waiter-overlay { - position: fixed; - height: 919px; - width: 1375px; - top: 0px; - left: 0px; - background-color: rgba(51, 62, 72, 0.5) !important; - } - ") - ), - # Define network plot title/subtitle divs - shiny::HTML( - paste0( - '