diff --git a/.Rbuildignore b/.Rbuildignore index 72b6303a..07076337 100644 --- a/.Rbuildignore +++ b/.Rbuildignore @@ -11,3 +11,4 @@ ^data-raw$ ^scrape-docs/ ^vignettes/ +^.*\.sass_cache_keys diff --git a/DESCRIPTION b/DESCRIPTION index 7edc0bc9..6263f1df 100644 --- a/DESCRIPTION +++ b/DESCRIPTION @@ -25,10 +25,11 @@ Depends: Imports: htmltools, jsonlite, - rlang, + purrr, shiny, shiny.react (>= 0.3.0) Suggests: + chromote, covr, dplyr, DT, @@ -39,7 +40,6 @@ Suggests: leaflet, mockery, plotly, - purrr, rcmdcheck, RColorBrewer, rmarkdown, @@ -47,6 +47,7 @@ Suggests: shiny.i18n (>= 0.3.0), shiny.router (>= 0.3.1), shinyjs, + shinytest2, sortable, stringi, testthat (>= 3.0.0), diff --git a/NAMESPACE b/NAMESPACE index 26b79111..ebbf8890 100644 --- a/NAMESPACE +++ b/NAMESPACE @@ -19,6 +19,7 @@ export(ColorPicker.shinyInput) export(ComboBox) export(ComboBox.shinyInput) export(CommandBar) +export(CommandBar.shinyInput) export(CommandBarButton) export(CommandBarButton.shinyInput) export(CommandBarItem) diff --git a/R/extensions.R b/R/extensions.R index 9cd63db8..12d4cba2 100644 --- a/R/extensions.R +++ b/R/extensions.R @@ -1,27 +1,69 @@ +setInputValue <- function(inputId, value, event = TRUE) { + fmt <- if (event) { + "() => Shiny.setInputValue('%s', %s, { priority: 'event' })" + } else { + "() => Shiny.setInputValue('%s', %s)" + } + JS(sprintf(fmt, inputId, if (!is.numeric(value)) sprintf("'%s'", value) else value)) +} + +commandBarItem <- "CommandBarItem" + +isCommandBarItem <- function(x) { + isTRUE(attr(x, "componentName") == commandBarItem) +} + #' Command bar item #' -#' Helper function for constructing items for `CommandBar`. -#' -#' CommandBar expects items definition as a nested structure, which gets lengthy and verbose. -#' This function helps makes this definition shorter. Returns a list with all arguments passed through, -#' except for `text`, `icon` (which will inserted as proper `iconProps`) and `subitems` (which will be inserted as -#' proper `subMenuProps`). +#' Helper function for constructing items for `CommandBar` and `CommandBar.shinyInput`. #' +#' @param key Key of the item. #' @param text Text to be displayed on the menu. -#' @param icon Optional name of an icon. -#' @param subitems Optional list of CommandBar items. +#' @param onClick A JS function that runs on item click. By default it sends input value to `input[[key]]`. +#' If used within `CommandBar.shinyInput`, it will send the value to the input ID specified +#' in `inputId` argument of `CommandBar.shinyInput`. #' @param ... Additional props to pass to CommandBarItem. -#' @return Item suitable for use in the CommandBar. +#' @return Item suitable for use in the `CommandBar` and `CommandBar.shinyInput`. #' #' @seealso CommandBar #' @export -CommandBarItem <- function(text, icon = NULL, subitems = NULL, ...) { - props <- rlang::dots_list(...) +CommandBarItem <- function( + key, + text, + onClick = setInputValue(inputId = key, value = 0, event = TRUE), + ... +) { + structure( + list( + key = key, + text = text, + onClick = onClick, + ... + ), + componentName = commandBarItem + ) +} - props$text <- text - if (is.character(icon)) props$iconProps <- list(iconName = icon) - if (!is.null(subitems)) props$subMenuProps <- list(items = subitems) - props +#' CommandBar.shinyInput +#' +#' @param inputId ID of the component. Value of the clicked CommandBarItem will be sent to this ID. +#' @param itemValueGetter A function that takes a CommandBarItem and returns a value to be sent to Shiny. By default it returns `key` of the item. +#' @rdname CommandBar +#' +#' @export +CommandBar.shinyInput <- function( + inputId, + ..., + itemValueGetter = function(el) el$key +) { + attachOnClick <- function(el) { + el$onClick <- setInputValue(inputId, itemValueGetter(el)) + el + } + args <- list(...) + args$items <- recursiveModify(args$items, attachOnClick, isCommandBarItem) + args$farItems <- recursiveModify(args$farItems, attachOnClick, isCommandBarItem) + do.call(CommandBar, args) } #' Basic Fluent UI page diff --git a/R/utils.R b/R/utils.R new file mode 100644 index 00000000..7e59954c --- /dev/null +++ b/R/utils.R @@ -0,0 +1,12 @@ +recursiveModify <- function(x, modify = identity, when = function(x) TRUE) { + purrr::modify_tree( + x, + pre = function(el) { + if (!is.null(el) && when(el)) { + return(modify(el)) + } + el + }, + is_node = is.list + ) +} diff --git a/inst/examples/CommandBar.R b/inst/examples/CommandBar.R index c0d7af9a..8c200df0 100644 --- a/inst/examples/CommandBar.R +++ b/inst/examples/CommandBar.R @@ -1,69 +1,91 @@ library(shiny) library(shiny.fluent) -items <- list( +items <- function(ns) { list( - key = "newItem", - text = "New", - cacheKey = "myCacheKey", - iconProps = list(iconName = "Add"), - subMenuProps = list( - items = list( - list( - key = "emailMessage", - text = "Email message", - iconProps = list(iconName = "Mail") - ), - list( - key = "calendarEvent", - text = "Calendar event", - iconProps = list(iconName = "Calendar") + CommandBarItem( + key = ns("newItem"), + text = "New", + cacheKey = "myCacheKey", + split = TRUE, + iconProps = list(iconName = "Add"), + subMenuProps = list( + items = list( + CommandBarItem( + key = ns("emailMessage"), + text = "Email message", + iconProps = list(iconName = "Mail") + ), + CommandBarItem( + key = ns("calendarEvent"), + text = "Calendar event", + iconProps = list(iconName = "Calendar") + ) ) ) + ), + CommandBarItem( + key = ns("upload"), + text = "Upload", + iconProps = list(iconName = "Upload") + ), + CommandBarItem( + key = ns("share"), + text = "Share", + iconProps = list(iconName = "Share") + ), + CommandBarItem( + key = ns("download"), + text = "Download", + iconProps = list(iconName = "Download") ) - ), - list( - key = "upload", - text = "Upload", - iconProps = list(iconName = "Upload") - ), - list( - key = "share", - text = "Share", - iconProps = list(iconName = "Share") - ), - list( - key = "download", - text = "Download", - iconProps = list(iconName = "Download") ) -) +} -farItems <- list( - list( - key = "tile", - text = "Grid view", - ariaLabel = "Grid view", - iconOnly = TRUE, - iconProps = list(iconName = "Tiles") - ), +farItems <- function(ns) { list( - key = "info", - text = "Info", - ariaLabel = "Info", - iconOnly = TRUE, - iconProps = list(iconName = "Info") + CommandBarItem( + key = ns("tile"), + text = "Grid view", + ariaLabel = "Grid view", + iconOnly = TRUE, + iconProps = list(iconName = "Tiles") + ), + CommandBarItem( + key = ns("info"), + text = "Info", + ariaLabel = "Info", + iconOnly = TRUE, + iconProps = list(iconName = "Info") + ) ) -) - +} ui <- function(id) { ns <- NS(id) - CommandBar(items = items, farItems = farItems) + tagList( + CommandBar( + items = items(ns), + farItems = farItems(ns) + ), + textOutput(ns("commandBarItems")), + CommandBar.shinyInput( + inputId = ns("commandBar"), + items = items(identity), + farItems = farItems(identity) + ), + textOutput(ns("commandBar")) + ) } server <- function(id) { - moduleServer(id, function(input, output, session) { }) + moduleServer(id, function(input, output, session) { + commandBarItemClicked <- reactiveVal() + observeEvent(input$newItem, commandBarItemClicked("newItem clicked (explicitly observed)")) + observeEvent(input$upload, commandBarItemClicked("upload clicked (explicitly observed)")) + output$commandBarItems <- renderText(commandBarItemClicked()) + output$commandBar <- renderText(input$commandBar) + }) } if (interactive()) { diff --git a/man/CommandBar.Rd b/man/CommandBar.Rd index 62c0406f..89de6518 100644 --- a/man/CommandBar.Rd +++ b/man/CommandBar.Rd @@ -1,14 +1,22 @@ % Generated by roxygen2: do not edit by hand -% Please edit documentation in R/components.R, R/documentation.R, R/examples.R +% Please edit documentation in R/components.R, R/documentation.R, R/examples.R, +% R/extensions.R \name{CommandBar} \alias{CommandBar} +\alias{CommandBar.shinyInput} \title{CommandBar} \usage{ CommandBar(...) + +CommandBar.shinyInput(inputId, ..., itemValueGetter = function(el) el$key) } \arguments{ \item{...}{Props to pass to the component. The allowed props are listed below in the \bold{Details} section.} + +\item{inputId}{ID of the component. Value of the clicked CommandBarItem will be sent to this ID.} + +\item{itemValueGetter}{A function that takes a CommandBarItem and returns a value to be sent to Shiny. By default it returns \code{key} of the item.} } \value{ Object with \code{shiny.tag} class suitable for use in the UI of a Shiny app. @@ -72,69 +80,91 @@ you need to work using the original docs to achieve the desired result. library(shiny) library(shiny.fluent) -items <- list( +items <- function(ns) { list( - key = "newItem", - text = "New", - cacheKey = "myCacheKey", - iconProps = list(iconName = "Add"), - subMenuProps = list( - items = list( - list( - key = "emailMessage", - text = "Email message", - iconProps = list(iconName = "Mail") - ), - list( - key = "calendarEvent", - text = "Calendar event", - iconProps = list(iconName = "Calendar") + CommandBarItem( + key = ns("newItem"), + text = "New", + cacheKey = "myCacheKey", + split = TRUE, + iconProps = list(iconName = "Add"), + subMenuProps = list( + items = list( + CommandBarItem( + key = ns("emailMessage"), + text = "Email message", + iconProps = list(iconName = "Mail") + ), + CommandBarItem( + key = ns("calendarEvent"), + text = "Calendar event", + iconProps = list(iconName = "Calendar") + ) ) ) + ), + CommandBarItem( + key = ns("upload"), + text = "Upload", + iconProps = list(iconName = "Upload") + ), + CommandBarItem( + key = ns("share"), + text = "Share", + iconProps = list(iconName = "Share") + ), + CommandBarItem( + key = ns("download"), + text = "Download", + iconProps = list(iconName = "Download") ) - ), - list( - key = "upload", - text = "Upload", - iconProps = list(iconName = "Upload") - ), - list( - key = "share", - text = "Share", - iconProps = list(iconName = "Share") - ), - list( - key = "download", - text = "Download", - iconProps = list(iconName = "Download") ) -) +} -farItems <- list( - list( - key = "tile", - text = "Grid view", - ariaLabel = "Grid view", - iconOnly = TRUE, - iconProps = list(iconName = "Tiles") - ), +farItems <- function(ns) { list( - key = "info", - text = "Info", - ariaLabel = "Info", - iconOnly = TRUE, - iconProps = list(iconName = "Info") + CommandBarItem( + key = ns("tile"), + text = "Grid view", + ariaLabel = "Grid view", + iconOnly = TRUE, + iconProps = list(iconName = "Tiles") + ), + CommandBarItem( + key = ns("info"), + text = "Info", + ariaLabel = "Info", + iconOnly = TRUE, + iconProps = list(iconName = "Info") + ) ) -) - +} ui <- function(id) { ns <- NS(id) - CommandBar(items = items, farItems = farItems) + tagList( + CommandBar( + items = items(ns), + farItems = farItems(ns) + ), + textOutput(ns("commandBarItems")), + CommandBar.shinyInput( + inputId = ns("commandBar"), + items = items(identity), + farItems = farItems(identity) + ), + textOutput(ns("commandBar")) + ) } server <- function(id) { - moduleServer(id, function(input, output, session) { }) + moduleServer(id, function(input, output, session) { + commandBarItemClicked <- reactiveVal() + observeEvent(input$newItem, commandBarItemClicked("newItem clicked")) + observeEvent(input$upload, commandBarItemClicked("upload clicked")) + output$commandBarItems <- renderText(commandBarItemClicked()) + output$commandBar <- renderText(input$commandBar) + }) } if (interactive()) { diff --git a/man/CommandBarItem.Rd b/man/CommandBarItem.Rd index 1e320ede..1412051e 100644 --- a/man/CommandBarItem.Rd +++ b/man/CommandBarItem.Rd @@ -4,28 +4,29 @@ \alias{CommandBarItem} \title{Command bar item} \usage{ -CommandBarItem(text, icon = NULL, subitems = NULL, ...) +CommandBarItem( + key, + text, + onClick = setInputValue(inputId = key, value = 0, event = TRUE), + ... +) } \arguments{ -\item{text}{Text to be displayed on the menu.} +\item{key}{Key of the item.} -\item{icon}{Optional name of an icon.} +\item{text}{Text to be displayed on the menu.} -\item{subitems}{Optional list of CommandBar items.} +\item{onClick}{A JS function that runs on item click. By default it sends input value to \code{input[[key]]}. +If used within \code{CommandBar.shinyInput}, it will send the value to the input ID specified +in \code{inputId} argument of \code{CommandBar.shinyInput}.} \item{...}{Additional props to pass to CommandBarItem.} } \value{ -Item suitable for use in the CommandBar. +Item suitable for use in the \code{CommandBar} and \code{CommandBar.shinyInput}. } \description{ -Helper function for constructing items for \code{CommandBar}. -} -\details{ -CommandBar expects items definition as a nested structure, which gets lengthy and verbose. -This function helps makes this definition shorter. Returns a list with all arguments passed through, -except for \code{text}, \code{icon} (which will inserted as proper \code{iconProps}) and \code{subitems} (which will be inserted as -proper \code{subMenuProps}). +Helper function for constructing items for \code{CommandBar} and \code{CommandBar.shinyInput}. } \seealso{ CommandBar diff --git a/tests/testthat/setup-disable-crashpad.R b/tests/testthat/setup-disable-crashpad.R new file mode 100644 index 00000000..73e56ee6 --- /dev/null +++ b/tests/testthat/setup-disable-crashpad.R @@ -0,0 +1,30 @@ +# ❯ checking for detritus in the temp directory ... NOTE +# Found the following files/directories: +# ‘Crashpad’ +# +# 0 errors ✔ | 0 warnings ✔ | 1 note ✖ +# Error: Error: R CMD check found NOTEs +# Flavors: ubuntu-22.04 (devel), ubuntu-22.04 (release), ubuntu-22.04 (oldrel) + +# References (shinytest2 github): +# 1. https://github.com/rstudio/shinytest2/blob/main/cran-comments.md +# 2. https://github.com/rstudio/shinytest2/blob/main/tests/testthat/setup-disable-crashpad.R + +# Disable crash reporting on CRAN machines. (Can't get the report anyways) +chromote::set_chrome_args(c( + # https://peter.sh/experiments/chromium-command-line-switches/#disable-crash-reporter + #> Disable crash reporter for headless. It is enabled by default in official builds + "--disable-crash-reporter", + chromote::default_chrome_args() +)) + +# Make sure the temp folder is removed when testing is complete +withr::defer({ + + # Clean up chromote sessions + gc() # Run R6 finalizer methods + Sys.sleep(2) # Wait for any supervisors to exit + + # Delete the Crashpad folder if it exists + unlink(file.path(tempdir(), "Crashpad"), recursive = TRUE) +}, envir = testthat::teardown_env()) diff --git a/tests/testthat/test-CommandBar.R b/tests/testthat/test-CommandBar.R new file mode 100644 index 00000000..a47a99e4 --- /dev/null +++ b/tests/testthat/test-CommandBar.R @@ -0,0 +1,178 @@ +init_driver <- function(app) { + shinytest2::AppDriver$new(app, variant = shinytest2::platform_variant()) +} + +items <- function() { + list( + CommandBarItem( + id = "new_item", + key = "new_item_value", + text = "New", + cacheKey = "myCacheKey", + split = TRUE, + iconProps = list(iconName = "Add"), + subMenuProps = list( + items = list( + CommandBarItem( + key = "email_message_value", + id = "email_message", + text = "Email message", + iconProps = list(iconName = "Mail") + ) + ) + ) + ), + CommandBarItem( + key = "download_value", + id = "download", + text = "Download", + iconProps = list(iconName = "Download") + ) + ) +} + +far_items <- function() { + list( + CommandBarItem( + key = "tile_value", + id = "tile", + text = "Grid view", + ariaLabel = "Grid view", + iconOnly = TRUE, + iconProps = list(iconName = "Tiles") + ) + ) +} + +describe("CommandBar", { + it("should set input after clicking on a CommandBarItem", { + skip_on_cran() + + # Arrange + app <- init_driver(shiny::shinyApp( + ui = shiny::tagList( + CommandBar( + items = items(), + farItems = far_items() + ) + ), + server = function(input, output) { } + )) + withr::defer(app$stop()) + + # Act + app$click(selector = "#download") + value <- app$get_value(input = "download_value") + + # Assert + expect_equal(value, 0) + }) +}) + + +describe("CommandBar.shinyInput", { + test_app <- function() { + shiny::shinyApp( + ui = shiny::tagList( + CommandBar.shinyInput( + inputId = "commandBar", + items = items(), + farItems = far_items() + ) + ), + server = function(input, output) { } + ) + } + + it("should yield NULL input value on startup", { + skip_on_cran() + + # Arrange + app <- init_driver(test_app()) + withr::defer(app$stop()) + + # Act + value <- app$get_value(input = "commandBar") + + # Assert + expect_null(value) + }) + + it("should set input after clicking on CommandBarItem with with `key` value of clicked item", { + skip_on_cran() + + # Arrange + app <- init_driver(test_app()) + withr::defer(app$stop()) + + # Act + app$click(selector = "#download") + value <- app$get_value(input = "commandBar") + + # Assert + expect_equal(value, "download_value") + }) + + it("should set input after clicking on nested CommandBarItem with `key` value of clicked item", { + skip_on_cran() + + # Arrange + app <- init_driver(test_app()) + withr::defer(app$stop()) + + # Act + # Click dropdown button which is a sibling of #new_item when using split = TRUE + app$click(selector = "#new_item + button") + app$click(selector = "#email_message") + value <- app$get_value(input = "commandBar") + + # Assert + expect_equal(value, "email_message_value") + }) + + it("should work with only `items` added to CommandBar", { + skip_on_cran() + + # Arrange + app <- init_driver(shiny::shinyApp( + ui = shiny::tagList( + CommandBar.shinyInput( + inputId = "commandBar", + items = items() + ) + ), + server = function(input, output) { } + )) + withr::defer(app$stop()) + + # Act + app$click(selector = "#download") + value <- app$get_value(input = "commandBar") + + # Assert + expect_equal(value, "download_value") + }) + + it("should work with only `farItems` added to CommandBar", { + skip_on_cran() + + # Arrange + app <- init_driver(shiny::shinyApp( + ui = shiny::tagList( + CommandBar.shinyInput( + inputId = "commandBar", + farItems = far_items() + ) + ), + server = function(input, output) { } + )) + withr::defer(app$stop()) + + # Act + app$click(selector = "#tile") + value <- app$get_value(input = "commandBar") + + # Assert + expect_equal(value, "tile_value") + }) +}) diff --git a/tests/testthat/test-utils.R b/tests/testthat/test-utils.R new file mode 100644 index 00000000..771fc835 --- /dev/null +++ b/tests/testthat/test-utils.R @@ -0,0 +1,45 @@ +describe("recursiveModify", { + it("should apply modify function to all elements meeting 'when' condition", { + # Arrange + makeItem <- function(...) { + structure( + list(...), + class = "item" + ) + } + data <- list( + makeItem( + x = 1, + subItems = list( + makeItem(x = 1), + makeItem(x = 2) + ) + ), + makeItem( + x = 2, + subItems = list( + makeItem(x = 1), + makeItem(x = 2) + ) + ), + makeItem( + x = 3, + subItems = list( + makeItem(x = 1), + makeItem(x = 2) + ) + ) + ) + modify_spy <- mockery::mock() + modify <- function(x) { + modify_spy() + x + } + + # Act + result <- recursiveModify(data, modify = modify, when = function(x) inherits(x, "item")) + + # Assert + mockery::expect_called(modify_spy, n = 9) + }) +})