diff --git a/.circleci/config.yml b/.circleci/config.yml index 3ecba496..741d3bfb 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -38,6 +38,8 @@ jobs: R -e 'install.packages("bs4Dash", repos = "https://cran.rstudio.com/")' R -e 'install.packages(c("shinyWidgets", "yaml", "DT", "writexl", "fresh", "miniUI", "shinyFeedback"))' R -e 'install.packages(c("canvasXpress", "waiter", "shinyjs", "openxlsx", "spelling", "colourpicker", "lifecycle"))' + R -e 'install.packages("reactable")' + R -e 'install.packages("openxlsx2", repos = "https://cloud.r-project.org")' - run: name: Session information and installed package versions @@ -80,8 +82,9 @@ jobs: name: Install R dependencies command: | R -e 'install.packages(c("bs4Dash", "shinyWidgets", "yaml", "DT", "writexl", "fresh", "miniUI", "shinyFeedback"))' - R -e 'install.packages(c("canvasXpress", "waiter", "shinyjs", "openxlsx", "spelling", "colourpicker", "lifecycle"))' + R -e 'install.packages(c("canvasXpress", "waiter", "shinyjs", "openxlsx", "openxlsx2", "spelling", "colourpicker", "lifecycle"))' R -e 'install.packages("covr")' + R -e 'install.packages("reactable")' - run: name: Session information and installed package versions diff --git a/DESCRIPTION b/DESCRIPTION index 2a2b1ff6..9a11a5d9 100644 --- a/DESCRIPTION +++ b/DESCRIPTION @@ -1,7 +1,7 @@ Package: periscope2 Type: Package Title: Enterprise Streamlined 'shiny' Application Framework Using 'bs4Dash' -Version: 0.2.4 +Version: 0.3.0 Authors@R: c( person("Mohammed", "Ali", role = c("aut", "cre"), email = "mohammed@aggregate-genius.com"), person("Constance", "Brett", role = c("ctb")), @@ -27,6 +27,7 @@ Imports: grDevices, lubridate, methods, + reactable, shinyFeedback, shinyWidgets, utils, @@ -43,6 +44,7 @@ Suggests: lattice, miniUI, openxlsx, + openxlsx2, rmarkdown, shinyjs, spelling, diff --git a/NAMESPACE b/NAMESPACE index 2ecfe94e..a789ce6b 100644 --- a/NAMESPACE +++ b/NAMESPACE @@ -18,6 +18,8 @@ export(downloadFile_AvailableTypes) export(downloadFile_ValidateTypes) export(downloadablePlot) export(downloadablePlotUI) +export(downloadableReactTable) +export(downloadableReactTableUI) export(downloadableTable) export(downloadableTableUI) export(get_url_parameters) diff --git a/NEWS.md b/NEWS.md index 70f30e08..b5196680 100644 --- a/NEWS.md +++ b/NEWS.md @@ -1,3 +1,16 @@ +# periscope2 0.3.0 + +## New Features +- Added new module *?downloadableReactTable* based on reactable package and downloadFile module + +## Enhancements +- Added openxlsx2 support while keeping openxlsx to support legacy apps and backwards compatibility. writexl package is used as last resort. +- Updated *?logViewerOutput* module to display log data using *?downloadableReactTable*. +- Added loglevel filtering according to level chosen- Added loglevel filtering according to level chosen +- Added new feature to *?downloadFile* module to control whether row names are to be written for tabular data. + +----- + # periscope2 0.2.4 ## Enhancements diff --git a/R/downloadFile.R b/R/downloadFile.R index c72c3da9..bf4681b9 100644 --- a/R/downloadFile.R +++ b/R/downloadFile.R @@ -61,7 +61,7 @@ #' logger = "", #' filenameroot = "mydownload1", #' datafxns = list(csv = reactiveVal(iris)), -#' aspectratio = 1) +#' row_names = FALSE) #' # multiple download types #' downloadFile(id = "object_id2", #' logger = "", @@ -119,6 +119,14 @@ downloadFileButton <- function(id, file_button_ui } +check_openxlsx2_availability <- function() { + length(find.package("openxlsx2", quiet = TRUE)) > 0 +} + +check_openxlsx_availability <- function() { + length(find.package("openxlsx", quiet = TRUE)) > 0 +} + #' downloadFile module server function #' @@ -137,6 +145,8 @@ downloadFileButton <- function(id, #' @param aspectratio the downloaded chart image width:height ratio (ex: #' 1 = square, 1.3 = 4:3, 0.5 = 1:2). Where not applicable for a download type #' it is ignored (e.g. data downloads). +#' @param row_names logical value indicating whether row names are to be written +#' for tabular data. Where not applicable for a download type it is ignored. #' #' @return no return value, called for downloading selected file type #' @@ -174,7 +184,7 @@ downloadFileButton <- function(id, #' logger = "", #' filenameroot = "mydownload1", #' datafxns = list(csv = reactiveVal(iris)), -#' aspectratio = 1) +#' row_names = FALSE) #' # multiple download types #' downloadFile(id = "object_id2", #' logger = "", @@ -189,7 +199,8 @@ downloadFile <- function(id, logger = NULL, filenameroot = "download", datafxns = NULL, - aspectratio = 1) { + aspectratio = 1, + row_names = TRUE) { shiny::moduleServer( id, function(input, output, session) { @@ -204,8 +215,12 @@ downloadFile <- function(id, filename = shiny::reactive({paste(rootname(), "csv", sep = ".")}), content = function(file) { if (!is.null(datafxns)) { - writeFile("csv", datafxns$csv(), file, logger, - shiny::reactive({paste(rootname(), "csv", sep = ".")})) + writeFile(type = "csv", + data = datafxns$csv(), + file = file, + show_rownames = row_names, + logger = logger, + filename = shiny::reactive({paste(rootname(), "csv", sep = ".")})) } }) @@ -213,8 +228,12 @@ downloadFile <- function(id, filename = shiny::reactive({paste(rootname(), "xlsx", sep = ".")}), content = function(file) { if (!is.null(datafxns)) { - writeFile("xlsx", datafxns$xlsx(), file, logger, - shiny::reactive({paste(rootname(), "xlsx", sep = ".")})) + writeFile(type = "xlsx", + data = datafxns$xlsx(), + file = file, + show_rownames = row_names, + logger = logger, + filename = shiny::reactive({paste(rootname(), "xlsx", sep = ".")})) } }) @@ -222,8 +241,12 @@ downloadFile <- function(id, filename = shiny::reactive({paste(rootname(), "tsv", sep = ".")}), content = function(file) { if (!is.null(datafxns)) { - writeFile("tsv", datafxns$tsv(), file, logger, - shiny::reactive({paste(rootname(), "tsv", sep = ".")})) + writeFile(type = "tsv", + data = datafxns$tsv(), + file = file, + show_rownames = row_names, + logger = logger, + filename = shiny::reactive({paste(rootname(), "tsv", sep = ".")})) } }) @@ -231,17 +254,31 @@ downloadFile <- function(id, filename = shiny::reactive({paste(rootname(), "txt", sep = ".")}), content = function(file) { if (!is.null(datafxns)) { - writeFile("txt", datafxns$txt(), file, logger, - shiny::reactive({paste(rootname(), "txt", sep = ".")})) + writeFile(type = "txt", + data = datafxns$txt(), + file = file, + show_rownames = row_names, + logger = logger, + filename = shiny::reactive({paste(rootname(), "txt", sep = ".")})) } }) - # filename is expected to be a reactive expression - writeFile <- function(type, data, file, logger, filename) { + + ## writeFile + ## + ## @param type type of file to write + ## @param data data to write + ## @param file file path to write to + ## @param show_rownames if TRUE, row names are written for tabular data + ## @param logger logger to use + ## @param filename name of downloaded file, expected to be a reactive expression + ## + ## @returns no return value + writeFile <- function(type, data, file, show_rownames, logger, filename) { + show_rownames <- isTRUE(show_rownames) + # tabular values if ((type == "csv") || (type == "tsv")) { - show_rownames <- attr(data, "show_rownames") - show_rownames <- !is.null(show_rownames) && show_rownames show_colnames <- TRUE if (show_rownames) { show_colnames <- NA @@ -256,17 +293,32 @@ downloadFile <- function(id, } # excel file else if (type == "xlsx") { - if (length(find.package("openxlsx", quiet = TRUE) > 0)) { - if ((inherits(data, "Workbook")) && ("openxlsx" %in% attributes(class(data)))) { - openxlsx::saveWorkbook(data, file) + + if (inherits(data, "wbWorkbook") && check_openxlsx2_availability()) { + openxlsx2::wb_save(data, file) + } else if (inherits(data, "Workbook") && + ("openxlsx" %in% attributes(class(data))) && + check_openxlsx_availability()) { + openxlsx::saveWorkbook(data, file) + } else { + # tabular data + if (check_openxlsx2_availability()) { + openxlsx2::write_xlsx(data, + file, + as_table = TRUE, + row_names = show_rownames) + } else if (check_openxlsx_availability()) { + openxlsx::write.xlsx(data, + file, + asTable = TRUE, + rowNames = show_rownames) } else { - show_rownames <- attr(data, "show_rownames") - openxlsx::write.xlsx(data, file, - asTable = TRUE, - rowNames = !is.null(show_rownames) && show_rownames) + if (show_rownames) { + logwarn("Downloading with 'writexl', row names are not included", + logger = logger) + } + writexl::write_xlsx(data, file) } - } else { - writexl::write_xlsx(data, file) } } # text file processing @@ -274,7 +326,7 @@ downloadFile <- function(id, if (inherits(data, "character")) { writeLines(data, file) } else if (is.data.frame(data) || is.matrix(data)) { - utils::write.table(data, file) + utils::write.table(data, file, row.names = show_rownames) } else { msg <- paste(type, "could not be processed") logwarn(msg) diff --git a/R/downloadablePlot.R b/R/downloadablePlot.R index 4c8cc6f1..32c50932 100644 --- a/R/downloadablePlot.R +++ b/R/downloadablePlot.R @@ -74,9 +74,9 @@ #' download_plot <- function() { #' ggplot(data = mtcars, aes(x = wt, y = mpg)) + #' geom_point(aes(color = cyl)) + -#' theme(legend.justification = c(1, 1), -#' legend.position = c(1, 1), -#' legend.title = element_blank()) + +#' theme(legend.justification = c(1, 1), +#' legend.position.inside = c(1, 1), +#' legend.title = element_blank()) + #' ggtitle("GGPlot Example ") + #' xlab("wt") + #' ylab("mpg") @@ -217,9 +217,9 @@ downloadablePlotUI <- function(id, #' download_plot <- function() { #' ggplot(data = mtcars, aes(x = wt, y = mpg)) + #' geom_point(aes(color = cyl)) + -#' theme(legend.justification = c(1, 1), -#' legend.position = c(1, 1), -#' legend.title = element_blank()) + +#' theme(legend.justification = c(1, 1), +#' legend.position.inside = c(1, 1), +#' legend.title = element_blank()) + #' ggtitle("GGPlot Example ") + #' xlab("wt") + #' ylab("mpg") @@ -244,13 +244,10 @@ downloadablePlot <- function(id, id, function(input, output, session) { downloadFile("dplotButtonID", logger, filenameroot, downloadfxns, aspectratio) - dpInfo <- shiny::reactiveValues(visibleplot = NULL, - downloadfxns = NULL) shiny::observe({ - dpInfo$visibleplot <- visibleplot() output$dplotOutputID <- shiny::renderPlot({ - plot <- dpInfo$visibleplot + plot <- visibleplot() if (inherits(plot, "grob")) { plot <- grid::grid.draw(plot) } @@ -259,15 +256,6 @@ downloadablePlot <- function(id, }) shiny::observe({ - if (length(downloadfxns) > 0) { - dpInfo$downloadfxns <- lapply(downloadfxns, do.call, list()) - rowct <- lapply(dpInfo$downloadfxns, is.null) - session$sendCustomMessage( - "downloadbutton_toggle", - message = list(btn = session$ns("dplotButtonDiv"), - rows = sum(unlist(rowct) == FALSE)) ) - } - output$displayButton <- shiny::reactive(length(downloadfxns) > 0) shiny::outputOptions(output, "displayButton", suspendWhenHidden = FALSE) }) diff --git a/R/downloadableReactTable.R b/R/downloadableReactTable.R new file mode 100644 index 00000000..6f88b9f8 --- /dev/null +++ b/R/downloadableReactTable.R @@ -0,0 +1,364 @@ +# ------------------------------------------------- +# -- Application Downloadable React Table Module -- +# ------------------------------------------------- + + +#' downloadableReactTable module UI function +#' +#' downloadableReactTable module is extending \code{?reactable} package table functions by creating +#' a custom high-functionality table paired with \link[periscope2]{downloadFile} button. +#' The table has the following default functionality:search, highlight functionality, infinite scrolling, sorting by columns and +#' returns a reactive dataset of selected items and table current state. +#' +#' \link[periscope2]{downloadFile} button will be hidden if \code{downloadableReactTableUI} parameter +#' \code{downloadtypes} is empty +#' +#' @param id character id for the object +#' @param downloadtypes vector of values for data download types +#' @param hovertext download button tooltip hover text +#' +#' @return list of downloadFileButton UI and reactable table and hidden inputs for contentHeight option +#' +#' @section Table Features: +#' \itemize{ +#' \item Consistent styling of the table +#' \item downloadFile module button functionality built-in to the table (it will be shown only if downloadtypes is defined) +#' \item Ability to show different data from the download data +#' \item Table is automatically fit to the window size with infinite +#' y-scrolling +#' \item Table search functionality including highlighting built-in +#' \item Multi-select built in, including reactive feedback on which table +#' items are selected +#' } +#' +#' @section Example: +#' \code{downloadableReactTableUI("mytableID", c("csv", "tsv"), +#' "Click Here")} +#' +#' @section Notes: +#' When there are no rows to download in any of the linked downloaddatafxns the +#' button will be hidden as there is nothing to download. +#' +#' @section Shiny Usage: +#' Call this function at the place in ui.R where the table should be placed. +#' +#' Paired with a call to \code{downloadableReactTable(id, ...)} +#' in server.R +#' +#' @seealso \link[periscope2]{downloadableReactTable} +#' @seealso \link[periscope2]{downloadFile} +#' @seealso \link[periscope2]{logViewerOutput} +#' @seealso \link[periscope2]{downloadFile} +#' @seealso \link[periscope2]{downloadFile_ValidateTypes} +#' @seealso \link[periscope2]{downloadFile_AvailableTypes} +#' @seealso \link[periscope2]{downloadablePlot} +#' +#' @examples +#' if (interactive()) { +#' library(shiny) +#' library(periscope2) +#' library(reactable) +#' +#' shinyApp( +#' ui = fluidPage(fluidRow(column( +#' width = 12, +#' downloadableReactTableUI( +#' id = "object_id1", +#' downloadtypes = c("csv", "tsv"), +#' hovertext = "Download the data here!")))), +#' server = function(input, output) { +#' table_state <- downloadableReactTable( +#' id = "object_id1", +#' table_data = reactiveVal(iris), +#' download_data_fxns = list(csv = reactiveVal(iris), tsv = reactiveVal(iris)), +#' selection_mode = "multiple", +#' pre_selected_rows = function() {c(1, 3, 5)}, +#' table_options = list(columns = list( +#' Sepal.Length = colDef(name = "Sepal Length"), +#' Sepal.Width = colDef(filterable = TRUE), +#' Petal.Length = colDef(show = FALSE), +#' Petal.Width = colDef(defaultSortOrder = "desc")), +#' showSortable = TRUE, +#' theme = reactableTheme( +#' borderColor = "#dfe2e5", +#' stripedColor = "#f6f8fa", +#' highlightColor = "#f0f5f9", +#' cellPadding = "8px 12px"))) +#' +#' observeEvent(table_state(), { print(table_state()) }) +#' }) +#' } +#' +#' @export +downloadableReactTableUI <- function(id, + downloadtypes = NULL, + hovertext = NULL) { + ns <- shiny::NS(id) + list( + shiny::conditionalPanel( + condition = "output.displayButton", + ns = ns, + shiny::span( + id = ns("reactTableButtonDiv"), + class = "periscope-downloadable-react-table-button", + style = ifelse(length(downloadtypes) > 0, "", "display:none"), + downloadFileButton(ns("reactTableButtonID"), + downloadtypes, + hovertext))), + reactable::reactableOutput(ns("reactTableOutputID")) + ) +} + + +#' downloadableReactTable module server function +#' +#' Server-side function for the downloadableReactTableUI. +#' +#' +#' @param id the ID of the Module's UI element +#' @param table_data reactive expression (or parameter-less function) that acts as table data source +#' @param selection_mode to enable row selection, set \code{selection_mode} value to either "single" for single row +#' selection or "multiple" for multiple rows selection, case insensitive. Any other value will +#' disable row selection. Row selection will be enabled by radio buttons in "single" selection +#' and checkboxes in "multiple" selection (default = NULL) +#' @param pre_selected_rows reactive expression (or parameter-less function) provides the rows indices of the rows to +#' be selected when the table is rendered. If selection_mode is disabled, this parameter will +#' have no effect. If selection_mode is "single" only the first row index will be used (default = NULL) +#' @param file_name_root the base name used for user-downloaded file. It can be either a character string +#' a reactive expression or a function returning a character string (default = 'data_file') +#' @param download_data_fxns a \strong{named} list of functions providing the data as return values. +#' The names for the list should be the same names as the ones used in the +#' downloadableReactTableUI (default = NULL) +#' @param pagination to enable table pagination (default = FALSE) +#' @param table_height max table height in pixels. Vertical scroll will be shown after that height value +#' @param show_rownames enable displaying rownames as a separate column (default = FALSE) +#' @param columns_filter enable the filtering input on each column in the table (default = FALSE) +#' @param global_search enable table global searching input to search and filter in all columns at once +#' (default = TRUE) +#' @param row_highlight enable highlighting rows upon mouse hover (default = TRUE) +#' @param row_striping add zebra-striped style to table rows (default = TRUE) +#' @param table_options optional table formatting parameters check \code{?reactable::reactable} for options full list. +#' Also see example below to see how to pass options (default = list()) +#' @param logger logger to use (default = NULL) +#' +#' @return A named list of two elements: +#' \itemize{ +#' \item selected_rows: data.frame of current selected rows +#' \item table_state: a list of the current table state. The list keys are +#' ("page", "pageSize", "pages", "sorted" and "selected"). +#' Review \code{?reactable::getReactableState} for more info. +#' } +#' +#' +#' @section Shiny Usage: +#' This function is not called directly by consumers - it is accessed in +#' server.R using the same id provided in \code{downloadableReactTableUI}: +#' +#' \strong{\code{downloadableReactTable(id)}} +#' +#' @seealso \link[periscope2]{downloadableReactTableUI} +#' @seealso \link[periscope2]{downloadFileButton} +#' @seealso \link[periscope2]{logViewerOutput} +#' @seealso \link[periscope2]{downloadFile} +#' @seealso \link[periscope2]{downloadFile_ValidateTypes} +#' @seealso \link[periscope2]{downloadFile_AvailableTypes} +#' @seealso \link[periscope2]{downloadablePlot} +#' +#' @examples +#' if (interactive()) { +#' library(shiny) +#' library(periscope2) +#' library(reactable) +#' +#' shinyApp( +#' ui = fluidPage(fluidRow(column( +#' width = 12, +#' downloadableReactTableUI( +#' id = "object_id1", +#' downloadtypes = c("csv", "tsv"), +#' hovertext = "Download the data here!")))), +#' server = function(input, output) { +#' table_state <- downloadableReactTable( +#' id = "object_id1", +#' table_data = reactiveVal(iris), +#' download_data_fxns = list(csv = reactiveVal(iris), tsv = reactiveVal(iris)), +#' selection_mode = "multiple", +#' pre_selected_rows = function() {c(1, 3, 5)}, +#' table_options = list(columns = list( +#' Sepal.Length = colDef(name = "Sepal Length"), +#' Sepal.Width = colDef(filterable = TRUE), +#' Petal.Length = colDef(show = FALSE), +#' Petal.Width = colDef(defaultSortOrder = "desc")), +#' theme = reactableTheme( +#' borderColor = "#dfe2e5", +#' stripedColor = "#f6f8fa", +#' highlightColor = "#f0f5f9", +#' cellPadding = "8px 12px"))) +#' +#' observeEvent(table_state(), { print(table_state()) }) +#' }) +#' } +#' +#' @export +downloadableReactTable <- function(id, + table_data, + selection_mode = NULL, + pre_selected_rows = NULL, + file_name_root = "data_file", + download_data_fxns = NULL, + pagination = FALSE, + table_height = 600, + show_rownames = FALSE, + columns_filter = FALSE, + global_search = TRUE, + row_highlight = TRUE, + row_striping = TRUE, + table_options = list(), + logger = NULL) { + shiny::moduleServer(id, + function(input, output, session) { + if (is.null(table_data) || !is.function(table_data)) { + logerror("'table_data' parameter must be a function or reactive expression.", logger = logger) + output$reactTableOutputID <- reactable::renderReactable({ NULL }) + } else { + table_react_params <- shiny::reactiveValues(table_data = NULL, + pre_selected_rows = NULL) + if (is.null(file_name_root)) { + logwarn("'file_name_root' parameter should not be NULL. Setting default value 'data_file'.", logger = logger) + file_name_root <- "data_file" + } + + downloadFile(id = "reactTableButtonID", + logger = logger, + filenameroot = file_name_root, + datafxns = download_data_fxns, + row_names = show_rownames) + shiny::observe({ + output$displayButton <- shiny::reactive(length(download_data_fxns) > 0) + shiny::outputOptions(output, "displayButton", suspendWhenHidden = FALSE) + }) + + if (!is.null(pre_selected_rows) && !is.function(pre_selected_rows)) { + logwarn("'pre_selected_rows' parameter must be a function or reactive expression. Setting default value NULL.", logger = logger) + pre_selected_rows <- NULL + } + + shiny::observe({ + if (!is.data.frame(table_data())) { + table_data <- shiny::reactiveVal(data.frame(table_data())) + } + + table_react_params$table_data <- table_data() + output$displayButton <- shiny::reactive((length(download_data_fxns) > 0)) + + if (NROW(table_react_params$table_data) == 0) { + output$displayButton <- shiny::reactive(FALSE) + + } + shiny::outputOptions(output, "displayButton", suspendWhenHidden = FALSE) + }) + + shiny::observe({ + table_react_params$pre_selected_rows <- NULL + if ((is.null(selection_mode) || !(tolower(selection_mode) %in% c("single", "multiple"))) && + !is.null(pre_selected_rows)) { + logwarn("'selection_mode' parameter must be either 'single' or 'multiple' to use 'pre_selected_rows' param. Setting default value NULL.", + logger = logger) + } else if (!is.null(pre_selected_rows) && !is.numeric(pre_selected_rows())) { + logwarn("'pre_selected_rows' parameter must be a function or reactive expression that returns numeric vector. Setting default value NULL.", + logger = logger) + } else if (!is.null(pre_selected_rows)) { + table_react_params$pre_selected_rows <- pre_selected_rows() + } + + }) + output$reactTableOutputID <- reactable::renderReactable({ + table_output <- NULL + + if (!all(is.na(table_react_params$table_data)) && + !is.null(table_react_params$table_data) && + (NCOL(table_react_params$table_data) > 0)) { + row_selection_mode <- NULL + if (!is.null(selection_mode) && (tolower(selection_mode) %in% c("single", "multiple"))) { + row_selection_mode <- tolower(selection_mode) + + if ((row_selection_mode == "single") && (length(table_react_params$pre_selected_rows) > 1)) { + logwarn("when 'selection_mode' is 'single', only first value of 'pre_selected_rows' will be used", + logger = logger) + table_react_params$pre_selected_rows <- table_react_params$pre_selected_rows[1] + } + + if (length(table_react_params$pre_selected_rows) > 0) { + excluded_indices <- table_react_params$pre_selected_rows[ + sapply(table_react_params$pre_selected_rows, + function(x) { + x < 1 || x > NROW(table_react_params$table_data) + })] + if (length(excluded_indices) > 0) { + if (length(excluded_indices) == length(table_react_params$pre_selected_rows)) { + logwarn("All 'pre_selected_rows' values are out of range. Setting default value NULL.", + logger = logger) + table_react_params$pre_selected_rows <- NULL + } else { + value_msg <- paste("value:", excluded_indices[1] ,"as it is") + if (length(excluded_indices) > 1) { + value_msg <- paste("values:", paste0(excluded_indices, collapse = ", "), "as they are") + } + + logwarn(paste("Excluding 'pre_selected_rows'" , value_msg, "out of range."), + logger = logger) + table_react_params$pre_selected_rows <- table_react_params$pre_selected_rows[ + !(table_react_params$pre_selected_rows %in% excluded_indices)] + } + } + } + } + + table_arguments <- list(data = table_react_params$table_data, + selection = row_selection_mode, + defaultSelected = table_react_params$pre_selected_rows, + pagination = pagination, + height = table_height, + rownames = show_rownames, + filterable = columns_filter, + searchable = global_search, + highlight = row_highlight, + striped = row_striping) + if (length(table_options) > 0) { + all_options <- methods::formalArgs(reactable::reactable) + unnamed_options <- append(table_options[names(table_options) == ""], + table_options[is.null(names(table_options))]) + invalid_options <- table_options[!(names(table_options) %in% all_options)] + invalid_options <- invalid_options[!(invalid_options %in% unnamed_options)] + valid_options <- table_options[names(table_options) %in% all_options] + + if (length(unnamed_options) > 0) { + logwarn(paste("Excluding the following unnamed option(s):", + paste(unnamed_options, collapse = ", ")), logger = logger) + } + + if (length(invalid_options) > 0) { + logwarn(paste("Excluding the following invalid option(s):", + paste(names(invalid_options), collapse = ", ")), logger = logger) + } + + table_arguments <- append(table_arguments, valid_options) + } + + table_output <- do.call(reactable::reactable, table_arguments) + + } + table_output + }) + } + shiny::reactive({ + table_state <- reactable::getReactableState("reactTableOutputID") + selected_rows <- NULL + if (!is.null(table_state) && !is.null(table_state$selected) && is.data.frame(table_data())) { + selected_rows <- table_data()[table_state$selected, ] + } + list(selected_rows = selected_rows, table_state = table_state) + }) + } + ) +} diff --git a/R/downloadableTable.R b/R/downloadableTable.R index 923291ca..8329c3f3 100644 --- a/R/downloadableTable.R +++ b/R/downloadableTable.R @@ -235,16 +235,15 @@ downloadableTable <- function(id, filenameroot <- shiny::isolate(filenameroot()) } - downloadFile("dtableButtonID", logger, filenameroot, downloaddatafxns) + downloadFile(id = "dtableButtonID", + logger = logger, + filenameroot = filenameroot, + datafxns = downloaddatafxns, + row_names = table_options$rownames) - session$sendCustomMessage("downloadbutton_toggle", - message = list(btn = session$ns("dtableButtonDiv"), - rows = -1)) - - dtInfo <- shiny::reactiveValues(selection = NULL, - selected = NULL, - tabledata = NULL, - downloaddatafxns = NULL) + dtInfo <- shiny::reactiveValues(selection = NULL, + selected = NULL, + tabledata = NULL) shiny::observe({ result <- list(mode = ifelse(input$dtableSingleSelect == "TRUE", "single", "multiple")) @@ -269,14 +268,6 @@ downloadableTable <- function(id, }) shiny::observe({ - if (length(downloaddatafxns) > 0) { - dtInfo$downloaddatafxns <- lapply(downloaddatafxns, do.call, list()) - - rowct <- lapply(dtInfo$downloaddatafxns, NROW) - session$sendCustomMessage("downloadbutton_toggle", - message = list(btn = session$ns("dtableButtonDiv"), - rows = sum(unlist(rowct)))) - } output$displayButton <- shiny::reactive(length(downloaddatafxns) > 0) shiny::outputOptions(output, "displayButton", suspendWhenHidden = FALSE) }) diff --git a/R/logViewer.R b/R/logViewer.R index 96e4910d..7324d79e 100644 --- a/R/logViewer.R +++ b/R/logViewer.R @@ -5,13 +5,14 @@ #' Display app logs #' -#' Creates a shiny table with table containing logged user actions. Table contents are auto updated whenever a user action is -#' logged. The id must match the same id configured in \bold{server.R} file upon calling \code{fw_server_setup} method +#' Display app log data in downloadableReactTable table containing logged user actions. Table contents are auto updated +#' whenever a user action is logged. User can search for logs, sort them by time and download them in CSV or TSV format. +#' The id must match the same id configured in \bold{server.R} file upon calling \code{fw_server_setup} method #' #' #' @param id character id for the object(default = "logViewer") #' -#' @return shiny tableOutput instance +#' @return downloadableReactTableUI instance #' #' @section Table columns: #' \itemize{ @@ -44,7 +45,9 @@ #' @seealso \link[periscope2]{downloadableTable} logViewerOutput <- function(id = "logViewer") { ns <- shiny::NS(id) - shiny::tableOutput(ns(id)) + downloadableReactTableUI(id = ns(id), + downloadtypes = c("csv", "tsv"), + hovertext = "Download application logs") } @@ -57,7 +60,7 @@ logViewerOutput <- function(id = "logViewer") { #' @param id - the ID of the Module's UI element #' @param logger - action logs to be displayed #' -#' @return Shiny table render expression containing the currently logged lines +#' @return downloadableReactTable instance with logged lines #' #' #' @section Shiny Usage: @@ -68,8 +71,10 @@ logViewer <- function(id = "logViewer", logger) { shiny::moduleServer( id, function(input, output, session) { - output[[id]] <- shiny::renderTable({ - lines <- logger() + get_log_data <- function() { + log_data <- data.frame() + lines <- logger() + if (length(lines) > 0) { out1 <- data.frame(orig = lines, stringsAsFactors = F) loc1 <- regexpr("\\[", out1$orig) @@ -83,10 +88,16 @@ logViewer <- function(id = "logViewer", logger) { out1$action <- substring(out1$orig, loc2 + 1) out1$action <- trimws(out1$action, "both") - data.frame(action = out1$action, - time = format(out1$timestamp, - format = .g_opts$datetime.fmt)) + log_data <- data.frame(action = out1$action, + time = format(out1$timestamp, + format = .g_opts$datetime.fmt)) } - }) + log_data + } + + downloadableReactTable(id = id, + table_data = get_log_data, + download_data_fxns = list(csv = get_log_data, tsv = get_log_data), + file_name_root = "log_data") }) } diff --git a/R/logger.R b/R/logger.R index 73dd8630..301aeda5 100644 --- a/R/logger.R +++ b/R/logger.R @@ -622,6 +622,17 @@ updateOptions.Logger <- function(container, ...) { updateOptions.environment(container, ...) } +## Filtering log messages according to chosen level +## +logging_level <- function(level_name, current_log_level) { + switch(current_log_level, + "INFO" = level_name %in% c("INFO", "WARNING", "ERROR"), + "WARNING" = level_name %in% c("WARNING", "ERROR"), + "ERROR" = level_name == "ERROR", + TRUE + ) +} + ## ## Predefined(sample) handler actions ## @@ -679,9 +690,13 @@ writeToConsole <- function(msg, handler, ...) { stopifnot(length(list(...)) > 0) - level_name <- list(...)[[1]]$levelname - msg <- handler$color_msg(msg, level_name) - cat(paste0(msg, "\n")) + current_log_level <- fw_get_loglevel() + level_name <- list(...)[[1]]$levelname + + if (logging_level(level_name, current_log_level)) { + msg <- handler$color_msg(msg, level_name) + cat(paste0(msg, "\n")) + } } .build_msg_coloring <- function() { @@ -727,7 +742,13 @@ writeToConsole <- function(msg, handler, ...) { writeToFile <- function(msg, handler, ...) { if (length(list(...)) && "dry" %in% names(list(...))) return(exists("file", envir = handler)) - cat(paste0(msg, "\n"), file = with(handler, file), append = TRUE) + + current_log_level <- fw_get_loglevel() + level_name <- list(...)[[1]]$levelname + + if (logging_level(level_name, current_log_level)) { + cat(paste0(msg, "\n"), file = with(handler, file), append = TRUE) + } } ## the single predefined formatter diff --git a/R/ui_helpers.R b/R/ui_helpers.R index 858989f7..f2edfe13 100644 --- a/R/ui_helpers.R +++ b/R/ui_helpers.R @@ -567,7 +567,7 @@ ui_tooltip <- function(id, #' application title.} #' \item{Supplying \strong{NULL} will disable the title link functionality.} #' } -#' @param log_level Designating the log level to use for the user log as 'DEBUG','INFO', 'WARN' or 'ERROR' (default = 'DEBUG') +#' @param log_level Designating the log level to use for the user log as 'DEBUG','INFO', 'WARNING' or 'ERROR' (default = 'DEBUG') #' @param app_version Character string designating the application version (default = '1.0.0') #' @param loading_indicator It uses waiter (see https://waiter.john-coene.com/#/).\cr #' Pass a list like list(html = spin_1(), color = "#333e48") to \cr configure diff --git a/README.md b/README.md index 554b6395..e334e3d3 100644 --- a/README.md +++ b/README.md @@ -22,7 +22,7 @@ output: * Predefined but flexible templates for new Shiny applications with a default [bs4Dash](https://bs4dash.rinterface.com/) layout * Separation by file of functionality that exists in one of the three shiny scopes: global, server-global, and server-local * Generated applications are organized in an easy to follow and maintain folder structure based on files functionality -* Off-the-shelf and ready to be used modules ('Table Downloader', 'Plot Downloader', 'File Downloader' and 'Reset Application' +* Off-the-shelf and ready to be used modules ('Table/React Table Downloader', 'Plot Downloader', 'File Downloader' and 'Reset Application' * Different methods and tools to alert users and add useful information about application UI and server operations * Application logger with different levels and a UI tool to display and review recorded application logs * Application look and feel can be customized easily via 'www/periscope_style.yaml' or more advanced via 'www/css/custom.css' @@ -120,6 +120,7 @@ User can adapt layout for both packages generated apps easily via related functi - Both packages share the following modules: - downloadable file + - Use openxlsx2 to download .xlsx files, openxlsx for legacy apps and backwards compatibility, and writexl as a fallback in case openxlsx2 or openxlsx not installed. - downloadable plot - downloadable table - However, periscope2 has more modules (more to come with each new version) as Announcements module diff --git a/cran-comments.md b/cran-comments.md index 88cceef0..b0e49e7c 100644 --- a/cran-comments.md +++ b/cran-comments.md @@ -12,7 +12,7 @@ RStudio 2023.06.1+524 (Windows 11 x64 (build 22621)) CircleCI * R 4.0.5 -* R 4.5.0 +* R 4.5.1 devtools diff --git a/docs/404.html b/docs/404.html index 9c31e350..da33cbf0 100644 --- a/docs/404.html +++ b/docs/404.html @@ -32,7 +32,7 @@ @@ -60,6 +60,9 @@
vignettes/announcement-module.Rmd
announcement-module.Rmdvignettes/announcement_addin.Rmd
announcement_addin.Rmdvignettes/applicationReset-module.Rmd
applicationReset-module.Rmdvignettes/downloadFile-module.Rmd
downloadFile-module.Rmdvignettes/downloadablePlot-module.Rmd
downloadablePlot-module.Rmdvignettes/downloadableReactTable-module.Rmd
+ downloadableReactTable-module.RmdThe document explains how to use +downloadableReactTable shiny module in periscope2 +applications.
+downloadableReactTable parameter
+download_data_fxns or downloadableReactTableUI
+parameter downloadtypes is emptyShiny modules consist of a pair of functions that modularize, or +package, a small piece of reusable functionality. The UI function is +called directly by the user to place the UI in the correct location (as +with other shiny UI objects). The module server function that is called +only once to set it up using the module name as a function inside the +server function (i.e. user-local session scope. The first function +argument is a string that represents the module id (the same id used in +module UI function). Additional arguments can be supplied by the user +based on the specific shiny module that is called. There can be +additional helper functions that are a part of a shiny module.
+The downloadableReactTable Shiny Module is a part of +the periscope2 package and consists of the following +functions:
+The downloadableReactTableUI function is called from +the ui.R (or equivalent) file in the location where the table should be +placed. This is similar to other UI element placement in shiny.
+The downloadableReactTableUI looks like:
+
+The downloadableReactTableUI function takes the unique object ID for +the UI object.
+The next two arguments (downloadtypes and hovertext) are passed to +the downloadFileButton and set the file types the button will allow the +user to request and the downloadFileButton’s tooltip text.
+
+# Inside ui_body.R or ui_sidebar.R
+
+ downloadableReactTableUI(
+ id = "object_id1",
+ downloadtypes = c("csv", "tsv"),
+ hovertext = "Download the data here!")The downloadableReactTable function is called +directly. The call consists of the following:
+Data Function or Reactive Expression +Requirements
+Reactive Return Value
+* Note that this is the data, not references, rownumbers, etc from the table -- it is the actual, visible, table row data. This allows the developer to use this more easily to update another table, chart, etc. as desired.
+Customization Options
+downloadableReactTable module can be customized using
+reactable function arguments(see ?reactable::reactable).
+These options can be sent as a named options via the server function,
+see example below.
+# Inside server_local.R
+ library(shiny)
+ library(periscope2)
+ library(reactable)
+
+table_state <- downloadableReactTable(
+ id = "object_id1",
+ table_data = reactiveVal(iris),
+ download_data_fxns = list(csv = reactiveVal(iris), tsv = reactiveVal(iris)),
+ selection_mode = "multiple",
+ pre_selected_rows = function() {c(1, 3, 5)},
+ table_options = list(columns = list(
+ Sepal.Length = colDef(name = "Sepal Length"),
+ Sepal.Width = colDef(filterable = TRUE),
+ Petal.Length = colDef(show = FALSE),
+ Petal.Width = colDef(defaultSortOrder = "desc")),
+ showSortable = TRUE,
+ theme = reactableTheme(
+ borderColor = "#dfe2e5",
+ stripedColor = "#f6f8fa",
+ highlightColor = "#f0f5f9",
+ cellPadding = "8px 12px")))
+
+ observeEvent(table_state(), { print(table_state()) })
+
+# NOTE: table_state is the reactive return value, captured for later useFor a complete running shiny example application using the +downloadableReactTable module you can create and run a +periscope2 sample application using:
+
+library(periscope2)
+
+app_dir = tempdir()
+create_new_application(name = 'mysampleapp', location = app_dir, sample_app = TRUE)
+runApp(paste(app_dir, 'mysampleapp', sep = .Platform$file.sep))vignettes/downloadableTable-module.Rmd
downloadableTable-module.Rmdvignettes/logViewer-module.Rmd
logViewer-module.Rmdvignettes/new-application.Rmd
new-application.Rmdvignettes/themeBuilder_addin.Rmd
themeBuilder_addin.RmdAli M (2025). periscope2: Enterprise Streamlined 'shiny' Application Framework Using 'bs4Dash'. -R package version 0.2.4, http://periscopeapps.org:3838, https://github.com/Aggregate-Genius/periscope2. +R package version 0.3.0, http://periscopeapps.org:3838, https://github.com/Aggregate-Genius/periscope2.
@Manual{,
title = {periscope2: Enterprise Streamlined 'shiny' Application Framework Using 'bs4Dash'},
author = {Mohammed Ali},
year = {2025},
- note = {R package version 0.2.4, http://periscopeapps.org:3838},
+ note = {R package version 0.3.0, http://periscopeapps.org:3838},
url = {https://github.com/Aggregate-Genius/periscope2},
}
diff --git a/docs/index.html b/docs/index.html
index c2113cc5..7f4d5b0a 100644
--- a/docs/index.html
+++ b/docs/index.html
@@ -33,7 +33,7 @@
@@ -61,6 +61,9 @@
set_app_parameters method documentation to displayed html tags correctly# sample app named 'mytestapp' created in a temp dir
location <- tempdir()
create_application(name = 'mytestapp', location = location, sample_app = TRUE)
-#> periscope2 application mytestapp was created successfully at /tmp/RtmpBjjCUk
+#> periscope2 application mytestapp was created successfully at /tmp/Rtmpk1yVfS
unlink(paste0(location,'/mytestapp'), TRUE)
# sample app named 'mytestapp' with a right sidebar using a custom icon created in a temp dir
location <- tempdir()
create_application(name = 'mytestapp', location = location, sample_app = TRUE, right_sidebar = TRUE)
-#> periscope2 application mytestapp was created successfully at /tmp/RtmpBjjCUk
+#> periscope2 application mytestapp was created successfully at /tmp/Rtmpk1yVfS
unlink(paste0(location,'/mytestapp'), TRUE)
# blank app named 'myblankapp' created in a temp dir
location <- tempdir()
create_application(name = 'myblankapp', location = location)
-#> periscope2 application myblankapp was created successfully at /tmp/RtmpBjjCUk
+#> periscope2 application myblankapp was created successfully at /tmp/Rtmpk1yVfS
unlink(paste0(location,'/myblankapp'), TRUE)
# blank app named 'myblankapp' without a left sidebar created in a temp dir
location <- tempdir()
create_application(name = 'myblankapp', location = location, left_sidebar = FALSE)
-#> periscope2 application myblankapp was created successfully at /tmp/RtmpBjjCUk
+#> periscope2 application myblankapp was created successfully at /tmp/Rtmpk1yVfS
unlink(paste0(location,'/myblankapp'), TRUE)
logical value indicating whether row names are to be written +for tabular data. Where not applicable for a download type it is ignored.
R/downloadableReactTable.R
+ downloadableReactTable.RdServer-side function for the downloadableReactTableUI.
+downloadableReactTable(
+ id,
+ table_data,
+ selection_mode = NULL,
+ pre_selected_rows = NULL,
+ file_name_root = "data_file",
+ download_data_fxns = NULL,
+ pagination = FALSE,
+ table_height = 600,
+ show_rownames = FALSE,
+ columns_filter = FALSE,
+ global_search = TRUE,
+ row_highlight = TRUE,
+ row_striping = TRUE,
+ table_options = list(),
+ logger = NULL
+)the ID of the Module's UI element
reactive expression (or parameter-less function) that acts as table data source
to enable row selection, set selection_mode value to either "single" for single row
+selection or "multiple" for multiple rows selection, case insensitive. Any other value will
+disable row selection. Row selection will be enabled by radio buttons in "single" selection
+and checkboxes in "multiple" selection (default = NULL)
reactive expression (or parameter-less function) provides the rows indices of the rows to +be selected when the table is rendered. If selection_mode is disabled, this parameter will +have no effect. If selection_mode is "single" only the first row index will be used (default = NULL)
the base name used for user-downloaded file. It can be either a character string +a reactive expression or a function returning a character string (default = 'data_file')
a named list of functions providing the data as return values. +The names for the list should be the same names as the ones used in the +downloadableReactTableUI (default = NULL)
to enable table pagination (default = FALSE)
max table height in pixels. Vertical scroll will be shown after that height value
enable displaying rownames as a separate column (default = FALSE)
enable the filtering input on each column in the table (default = FALSE)
enable table global searching input to search and filter in all columns at once +(default = TRUE)
enable highlighting rows upon mouse hover (default = TRUE)
add zebra-striped style to table rows (default = TRUE)
optional table formatting parameters check ?reactable::reactable for options full list.
+Also see example below to see how to pass options (default = list())
logger to use (default = NULL)
A named list of two elements:
selected_rows: data.frame of current selected rows
table_state: a list of the current table state. The list keys are
+("page", "pageSize", "pages", "sorted" and "selected").
+Review ?reactable::getReactableState for more info.
This function is not called directly by consumers - it is accessed in
+server.R using the same id provided in downloadableReactTableUI:
downloadableReactTable(id)
if (interactive()) {
+ library(shiny)
+ library(periscope2)
+ library(reactable)
+
+ shinyApp(
+ ui = fluidPage(fluidRow(column(
+ width = 12,
+ downloadableReactTableUI(
+ id = "object_id1",
+ downloadtypes = c("csv", "tsv"),
+ hovertext = "Download the data here!")))),
+ server = function(input, output) {
+ table_state <- downloadableReactTable(
+ id = "object_id1",
+ table_data = reactiveVal(iris),
+ download_data_fxns = list(csv = reactiveVal(iris), tsv = reactiveVal(iris)),
+ selection_mode = "multiple",
+ pre_selected_rows = function() {c(1, 3, 5)},
+ table_options = list(columns = list(
+ Sepal.Length = colDef(name = "Sepal Length"),
+ Sepal.Width = colDef(filterable = TRUE),
+ Petal.Length = colDef(show = FALSE),
+ Petal.Width = colDef(defaultSortOrder = "desc")),
+ theme = reactableTheme(
+ borderColor = "#dfe2e5",
+ stripedColor = "#f6f8fa",
+ highlightColor = "#f0f5f9",
+ cellPadding = "8px 12px")))
+
+ observeEvent(table_state(), { print(table_state()) })
+ })
+}
+
+R/downloadableReactTable.R
+ downloadableReactTableUI.RddownloadableReactTable module is extending ?reactable package table functions by creating
+a custom high-functionality table paired with downloadFile button.
+The table has the following default functionality:search, highlight functionality, infinite scrolling, sorting by columns and
+returns a reactive dataset of selected items and table current state.
downloadableReactTableUI(id, downloadtypes = NULL, hovertext = NULL)list of downloadFileButton UI and reactable table and hidden inputs for contentHeight option
+downloadFile button will be hidden if downloadableReactTableUI parameter
+downloadtypes is empty
Consistent styling of the table
downloadFile module button functionality built-in to the table (it will be shown only if downloadtypes is defined)
Ability to show different data from the download data
Table is automatically fit to the window size with infinite +y-scrolling
Table search functionality including highlighting built-in
Multi-select built in, including reactive feedback on which table +items are selected
downloadableReactTableUI("mytableID", c("csv", "tsv"),
+"Click Here")
When there are no rows to download in any of the linked downloaddatafxns the +button will be hidden as there is nothing to download.
+Call this function at the place in ui.R where the table should be placed.
+Paired with a call to downloadableReactTable(id, ...)
+in server.R
if (interactive()) {
+ library(shiny)
+ library(periscope2)
+ library(reactable)
+
+ shinyApp(
+ ui = fluidPage(fluidRow(column(
+ width = 12,
+ downloadableReactTableUI(
+ id = "object_id1",
+ downloadtypes = c("csv", "tsv"),
+ hovertext = "Download the data here!")))),
+ server = function(input, output) {
+ table_state <- downloadableReactTable(
+ id = "object_id1",
+ table_data = reactiveVal(iris),
+ download_data_fxns = list(csv = reactiveVal(iris), tsv = reactiveVal(iris)),
+ selection_mode = "multiple",
+ pre_selected_rows = function() {c(1, 3, 5)},
+ table_options = list(columns = list(
+ Sepal.Length = colDef(name = "Sepal Length"),
+ Sepal.Width = colDef(filterable = TRUE),
+ Petal.Length = colDef(show = FALSE),
+ Petal.Width = colDef(defaultSortOrder = "desc")),
+ showSortable = TRUE,
+ theme = reactableTheme(
+ borderColor = "#dfe2e5",
+ stripedColor = "#f6f8fa",
+ highlightColor = "#f0f5f9",
+ cellPadding = "8px 12px")))
+
+ observeEvent(table_state(), { print(table_state()) })
+ })
+}
+
+downloadablePlotUI()
downloadablePlot module UI function
downloadableReactTable module server function
downloadableReactTable module UI function
Creates a shiny table with table containing logged user actions. Table contents are auto updated whenever a user action is
-logged. The id must match the same id configured in server.R file upon calling fw_server_setup method
Display app log data in downloadableReactTable table containing logged user actions. Table contents are auto updated
+whenever a user action is logged. User can search for logs, sort them by time and download them in CSV or TSV format.
+The id must match the same id configured in server.R file upon calling fw_server_setup method
shiny tableOutput instance
+downloadableReactTableUI instance
# Inside ui_body add the log viewer box to your box list
logViewerOutput('logViewerId')
-#> <div id="logViewerId-logViewerId" class="shiny-html-output"></div>
+#> [[1]]
+#> <div class="shiny-panel-conditional" data-display-if="output.displayButton" data-ns-prefix="logViewerId-logViewerId-">
+#> <span id="logViewerId-logViewerId-reactTableButtonDiv" class="periscope-downloadable-react-table-button" style="">
+#> <span class="btn-group" data-toggle="tooltip" data-placement="top" title="Download application logs">
+#> <button aria-expanded="false" aria-haspopup="true" class="btn btn-default action-button dropdown-toggle periscope-download-btn" data-toggle="dropdown" id="logViewerId-logViewerId-reactTableButtonID-downloadFileList" type="button action">
+#> <i class="far fa-copy" role="presentation" aria-label="copy icon"></i>
+#> </button>
+#> <ul class="dropdown-menu" id="logViewerId-logViewerId-reactTableButtonID-testList">
+#> <li>
+#> <a aria-disabled="true" class="shiny-download-link disabled periscope-download-choice" download href="" id="logViewerId-logViewerId-reactTableButtonID-csv" tabindex="-1" target="_blank">csv</a>
+#> </li>
+#> <li>
+#> <a aria-disabled="true" class="shiny-download-link disabled periscope-download-choice" download href="" id="logViewerId-logViewerId-reactTableButtonID-tsv" tabindex="-1" target="_blank">tsv</a>
+#> </li>
+#> </ul>
+#> </span>
+#> </span>
+#> </div>
+#>
+#> [[2]]
+#> <div class="reactable html-widget html-widget-output shiny-report-size html-fill-item" data-reactable-output="logViewerId-logViewerId-reactTableOutputID" id="logViewerId-logViewerId-reactTableOutputID" style="width:auto;height:auto;"></div>
+#>
Designating the log level to use for the user log as 'DEBUG','INFO', 'WARN' or 'ERROR' (default = 'DEBUG')
Designating the log level to use for the user log as 'DEBUG','INFO', 'WARNING' or 'ERROR' (default = 'DEBUG')
| action | time |
|---|---|
| Be Sure to Remember to Log ALL user actions | 02-19-2022 14:03 |
| Sample Title (click for an info pop-up) started with log level <DEBUG> | 02-19-2022 14:03 |
| Application Reset requested by user. Resetting in 5 seconds | 02-19-2022 14:04 |

