diff --git a/DESCRIPTION b/DESCRIPTION index f9c62d06..cfb77120 100644 --- a/DESCRIPTION +++ b/DESCRIPTION @@ -1,6 +1,6 @@ Package: epoxy Title: String Interpolation for Documents, Reports and Apps -Version: 0.1.1.9000 +Version: 1.0.0 Authors@R: c( person("Garrick", "Aden-Buie", , "garrick@adenbuie.com", role = c("aut", "cre"), comment = c(ORCID = "0000-0002-7111-0077")), diff --git a/NEWS.md b/NEWS.md index 350234cc..4a9193d9 100644 --- a/NEWS.md +++ b/NEWS.md @@ -1,12 +1,30 @@ -# epoxy (development version) +# epoxy 1.0.0 -* `epoxy()` now adds a `.data` pronoun that allows you to refer to the list or - data frame passed into either the `.data` argument of `epoxy()` or the `data` - or `.data` chunk options. (#100) +## Breaking Changes + +* **Breaking change:** `epoxy_latex()` and the `epoxy_latex` chunk engine it + powers now use `<<` and `>>` to delimit inline expressions. Where you + previously may have used ``, please now use `<>`. This breaking + change was necessary to allow the `expr` to include common R operators like + `<` and `>`. (#107) + +* **Breaking change:** the `whisker` engine is now powered by + `epoxy_mustache()`, resulting in a few small changes. In particular, if you + previously used a list for the `.data` chunk option in a `whisker` chunk, and + relied on the `whisker` engine's special treatment of lists to iterate over + their items, you'll need to specifically opt into this behavior by adding a + `.vectorized = TRUE` chunk option. + + This chunk engine still vectorizes over rows in a data frame by default, where + it's much more likely to be the behavior you want, but bare lists require + specifically opting in. (#103) * `.data` is now the preferred chunk option for passing data frames or lists of - data to epoxy chunks. It works in `whisker` and `epoxy` chunks, and is more - consistent with the `.data` argument of `glue()` and `epoxy()`. (#102) + data to epoxy chunks, where previously `data` was used in some places. It + works in `whisker` and `epoxy` chunks, and is more consistent with the `.data` + argument of `glue()` and `epoxy()`. (#102) + +## New Features * New `epoxy_mustache()` provides an epoxy-style interface to the [mustache](https://mustache.github.io/) templating language, using the @@ -19,26 +37,29 @@ `epoxy()`, `epoxy_html()` or other epoxy-provided knitr chunk or file as a template. `epoxy_use_chunk()` lets you re-use a template chunk with new data, and `epoxy_use_file()` lets you repeatedly use a template stored in a file. - Both function also re-use the options from the template chunk or the options + Both functions also re-use the options from the template chunk or the options set in the YAML from matter of the template file. See `?epoxy_use` for more details about how options for both functions are determined. (#106, #116) -* **Breaking change:** now that the `whisker` engine is powered by - `epoxy_mustache()`, there have been a few small changes. In particular, if you - previously used a list for the `.data` chunk option in a `whisker` chunk, and - relied on the `whisker` engine's special treatment of lists to iterate over - their items, you'll need to specifically opt into this behavior by adding a - `.vectorized = TRUE` chunk option. +## Improvements and bug fixes - This chunk engine still vectorizes over rows in a data frame by default, where - it's much more likely to be the behavior you want, but bare lists require - specifically opting in. (#103) +* `epoxy()` now adds a `.data` pronoun that allows you to refer to the list or + data frame passed into either the `.data` argument of `epoxy()` or the `data` + or `.data` chunk options. (#100) -* **Breaking change:** `epoxy_latex()` and the `epoxy_latex` chunk engine it - powers now use `<<` and `>>` to delimit inline expressions. Where you - previously may have used ``, please now use `<>`. This breaking - change was necessary to allow the `expr` to include common R operators like - `<` and `>`. (#107) +* `epoxy_html()` now supports inline transformations prefixed with `@` instead + of `.`, e.g. `@strong` instead of `.strong`. Previously, you would have to + place the inline transformer in a nested `{{ }}` block, e.g. + `{{ {{ .strong expr }} }}`, but now you only need `{{@strong expr}}`. To combine the HTML transformer (`epoxy_transform_html()`) with the inline + transformer, you still need to nest: `{{.text-muted {{@strong expr}}}}` + creates `{expr}`. (#120) + +* `epoxy()`, and by extension the LaTex and HTML counterparts, and all `epoxy_*` + knitr engines gain a `.collapse` argument to determine how a vector of + epoxy-transformed templates should be collapsed. The default is `NULL`, which + means that the output is returned as a vector. This argument is also useful in + `epoxy_use_chunk()` and for knitr chunks being used as a vectorized template. + (#115) * Aded `.sentence` (alias `.sc`) to the list of inline transformers provided by `epoxy_transform_inline()`. This transformer will capitalize the first letter @@ -49,27 +70,13 @@ `as.character()` before applying `tools::toTitleCase()`, since `toTitleCase()` will throw an error for non-character inputs. (#112) -* `epoxy()`, and by extension the LaTex and HTML counterparts, and all `epoxy_*` - knitr engines gain a `.collapse` argument to determine how a vector of - epoxy-transformed templates should be collapsed. The default is `NULL`, which - means that the output is returned as a vector. This argument is also useful in - `epoxy_use_chunk()` and for knitr chunks being used as a vectorized template. - (#115) - -* Fixed an issue with `epoxy_inline_transform()` when used with custom - delimiters (#116). - * `epoxy()`, `epoxy_html()`, `epoxy_latex()` and `epoxy_mustache()` (and their related knitr engines) will all collect remote `tbl_sql` tables before evaluation. This makes it much easier to pass data from a remote database using `{dplyr}` and `{dbplyr}`. (#117) -* `epoxy_html()` now supports inline transformations prefixed with `@` instead - of `.`, e.g. `@strong` instead of `.strong`. Previously, you would have to - place the inline transformer in a nested `{{ }}` block, e.g. - `{{ {{ .strong expr }} }}`, but now you only need `{{@strong expr}}`. To combine the HTML transformer (`epoxy_transform_html()`) with the inline - transformer, you still need to nest: `{{.text-muted {{@strong expr}}}}` - creates `{expr}`. (#120) +* Fixed an issue with `epoxy_inline_transform()` when used with custom + delimiters (#116). # epoxy 0.1.1 @@ -211,7 +218,7 @@ versions that were available on GitHub prior to the CRAN release. (#37). * `epoxy_transform_collapse()` now uses the - [and package](https://and.rossellhayes.com/), which provides language-aware + [and package](https://github.com/rossellhayes/and/), which provides language-aware conjoining of strings. As a result, the `sep_and` and `sep_or` arguments of `epoxy_transform_collapse()` are deprecated and are silently ignored if provided (#45). diff --git a/R/epoxy_use.R b/R/epoxy_use.R index 0c911c0a..577d5d39 100644 --- a/R/epoxy_use.R +++ b/R/epoxy_use.R @@ -111,8 +111,7 @@ epoxy_use_chunk <- function(.data = NULL, label, ...) { template$code, .data = .data, ..., - options = template$opts, - engine = template$opts$engine + options = template$opts ) } @@ -135,8 +134,7 @@ epoxy_use_file <- function(.data = NULL, file, ...) { template, .data = .data, ..., - options = options, - engine = options$engine %||% "epoxy" + options = options ) } @@ -144,10 +142,16 @@ read_body_without_yaml <- function(path) { x <- readLines(path) x_trimmed <- trimws(x) + if (!any(nzchar(x_trimmed))) { + rlang::abort(paste0("File '", path, "' is empty")) + } + idx_nzchar <- which(nzchar(x_trimmed))[1] - idx_start <- grep("^---$", x_trimmed)[1] + idx_start <- grep("^---$", x_trimmed) - if (idx_nzchar < idx_start) { + if (length(idx_start)) idx_start <- idx_start[1] + + if (length(idx_start) == 0 || idx_nzchar < idx_start) { return(paste(x, collapse = "\n")) } @@ -170,7 +174,7 @@ epoxy_use_template <- function( .data = NULL, ..., options = list(), - engine = options$engine + engine = NULL ) { # For options, we want to apply options in this order: # 0. `.data` from this fn and `eval` from this chunk @@ -190,17 +194,27 @@ epoxy_use_template <- function( opts <- purrr::list_assign(opts, !!!opts_current) opts <- purrr::list_assign(opts, !!!purrr::compact(opts_fn)) + engine <- engine %||% options$engine %||% "epoxy" + fn <- switch( engine, epoxy = epoxy, + html = , epoxy_html = epoxy_html, + latex = , epoxy_latex = epoxy_latex, - mustache = epoxy_mustache, + mustache = , whisker = epoxy_mustache, glue = epoxy, glue_html = epoxy_html, glue_latex = epoxy_latex, - epoxy + { + rlang::warn(c( + glue("Unexpected engine '{engine}', defaulting to `epoxy()`."), + "i" = "Set an epoxy knitr engine in the chunk or file." + )) + epoxy + } ) call <- rlang::call2( diff --git a/R/shiny.R b/R/shiny.R index 534a1323..0fcfe235 100644 --- a/R/shiny.R +++ b/R/shiny.R @@ -40,7 +40,7 @@ #' h2("ui_epoxy_html demo"), #' ui_epoxy_html( #' .id = "example", -#' .class_item = "inner", +#' .item_class = "inner", #' fluidRow( #' tags$div( #' class = "col-xs-4", @@ -101,13 +101,11 @@ #' @param .id The output id #' @param ... UI elements or text (that will be treated as HTML), containing #' template variables. Use named values to provide initial placeholder values. -#' @param .class Classes added to the output div, in addition to `.epoxy-html` -#' @param .class_item Classes added to the `.container` wrapping each template -#' variable. -#' @param .container The name of the HTML element to be used for the output -#' element, by default `"div"`. -#' @param .container_item The name of the HTML element to be used for each -#' template item, by default `"span"`. +#' @param .class,.style Classes and inline style directives added to the +#' `` container into which the elements in `...` are placed. +#' @param .item_tag,.item_class The HTML element tag name and classes used to +#' wrap each template variable. By default, each template is wrapped in a +#' ``. #' @param .placeholder Default placeholder if a template variable placeholder #' isn't provided. #' @param .aria_live,.aria_atomic The @@ -129,6 +127,12 @@ #' UI that have changed. #' @inheritParams epoxy #' @inheritParams glue::glue +#' @param .container `r lifecycle::badge("deprecated")` Deprecated in +#' \pkg{epoxy} v1.0.0, where the container is now always ``. +#' @param .class_item `r lifecycle::badge("deprecated")` Deprecated in +#' \pkg{epoxy} v1.0.0, please use `.item_class` instead. +#' @param .container_item `r lifecycle::badge("deprecated")` Deprecated in +#' \pkg{epoxy} v1.0.0, please use `.item_tag` instead. #' #' @seealso [ui_epoxy_mustache()], [render_epoxy()] #' @return An HTML object. @@ -137,9 +141,9 @@ ui_epoxy_html <- function( .id, ..., .class = NULL, - .class_item = NULL, - .container = "div", - .container_item = "span", + .style = NULL, + .item_tag = "span", + .item_class = NULL, .placeholder = "", .sep = "", .open = "{{", @@ -149,10 +153,43 @@ ui_epoxy_html <- function( .literal = FALSE, .trim = FALSE, .aria_live = c("polite", "off", "assertive"), - .aria_atomic = TRUE + .aria_atomic = TRUE, + # Deprecated arguments ---- + .class_item = deprecated(), + .container = deprecated(), + .container_item = deprecated() ) { - .container <- match.arg(.container, names(htmltools::tags)) - .container_item <- match.arg(.container_item, names(htmltools::tags)) + if (lifecycle::is_present(.container)) { + lifecycle::deprecate_warn( + "1.0.0", + "ui_epoxy_html(.container = )", + details = "This argument is no longer used." + ) + } + + if (lifecycle::is_present(.container_item)) { + lifecycle::deprecate_warn( + "1.0.0", + "ui_epoxy_html(.container_item = )", + "ui_epoxy_html(.item_container = )" + ) + if (missing(.item_tag)) { + .item_tag <- .container_item + } + } + + if (lifecycle::is_present(.class_item)) { + lifecycle::deprecate_warn( + "1.0.0", + "ui_epoxy_html(.class_item = )", + "ui_epoxy_html(.item_class = )" + ) + if (missing(.item_class)) { + .item_class <- .class_item + } + } + + .item_container <- match.arg(.item_tag, names(htmltools::tags)) .aria_live <- rlang::arg_match(.aria_live) .aria_atomic <- if (!is.null(.aria_atomic)) { @@ -161,7 +198,7 @@ ui_epoxy_html <- function( dots <- rlang::list2(...) dots$.placeholder <- .placeholder - dots$.transformer <- epoxyHTML_transformer(.class_item, .container_item) + dots$.transformer <- epoxyHTML_transformer(.item_class, .item_tag) dots$.na <- .na dots$.sep <- .sep dots$.null <- .null @@ -182,15 +219,19 @@ ui_epoxy_html <- function( res <- rlang::eval_bare(rlang::call2(glue::glue, !!!dots)) - out <- htmltools::tags[[.container]]( - id = .id, - class = "epoxy-html epoxy-init", - class = .class, - "aria-atomic" = .aria_atomic, - "aria-live" = .aria_live, - htmltools::HTML(res), - html_dependency_epoxy(), - html_dependency_hint_css() + out <- htmltools::tag( + "epoxy-html", + list( + id = .id, + class = "epoxy-html epoxy-init", + class = .class, + style = .style, + "aria-atomic" = .aria_atomic, + "aria-live" = .aria_live, + htmltools::HTML(res), + html_dependency_epoxy(), + html_dependency_hint_css() + ) ) if (!is.null(deps) && length(deps)) { htmltools::attachDependencies(out, deps) @@ -241,7 +282,7 @@ html_dependency_hint_css <- function() { #' [commonmark::markdown_html()]. #' @inheritParams ui_epoxy_html #' -#' @examplesIf rlang::is_installed("shiny") +#' @examplesIf rlang::is_installed("shiny") && rlang::is_interactive() #' library(shiny) #' #' # Shiny epoxy template functions don't support inline transformations, @@ -326,9 +367,9 @@ ui_epoxy_markdown <- function( .markdown_fn = NULL, .markdown_args = list(), .class = NULL, - .class_item = NULL, - .container = "div", - .container_item = "span", + .style = NULL, + .item_tag = "span", + .item_class = NULL, .placeholder = "", .sep = "", .open = "{{", @@ -338,7 +379,11 @@ ui_epoxy_markdown <- function( .literal = FALSE, .trim = FALSE, .aria_live = c("polite", "off", "assertive"), - .aria_atomic = TRUE + .aria_atomic = TRUE, + # Deprecated arguments ---- + .class_item = deprecated(), + .container = deprecated(), + .container_item = deprecated() ) { dots <- list_split_named(rlang::dots_list(...)) @@ -370,9 +415,9 @@ ui_epoxy_markdown <- function( htmltools::HTML(html), !!!dots, .class = .class, - .class_item = .class_item, - .container = .container, - .container_item = .container_item, + .style = .style, + .item_tag = .item_tag, + .item_class = .item_class, .placeholder = .placeholder, .sep = .sep, .open = .open, @@ -382,7 +427,11 @@ ui_epoxy_markdown <- function( .literal = .literal, .trim = .trim, .aria_live = .aria_live, - .aria_atomic = .aria_atomic + .aria_atomic = .aria_atomic, + # Deprecated arguments ---- + .class_item = .class_item, + .container = .container, + .container_item = .container_item ) } @@ -390,7 +439,7 @@ ui_epoxy_markdown <- function( #' alias, please use `ui_epoxy_html()`. #' @export epoxyHTML <- function(.id, ...) { - lifecycle::deprecate_soft( + lifecycle::deprecate_warn( "0.1.0", "epoxyHTML()", "ui_epoxy_html()", @@ -484,6 +533,12 @@ epoxyHTML_transformer <- function( #' }) #' #' favorites <- reactive({ +#' if (identical(input$fruits, "123456")) { +#' # Errors are equivalent to "empty" values, +#' # the rest of the template will still render. +#' stop("Bad fruits, bad!") +#' } +#' #' if (!nzchar(input$fruits)) return(NULL) #' list(fruits = strsplit(input$fruits, "\\s*,\\s*")[[1]]) #' }) @@ -520,14 +575,14 @@ ui_epoxy_mustache <- function( ..., .file = NULL, .sep = "", - .container = "div" + .container = "epoxy-mustache" ) { rlang::check_dots_unnamed() if (is.character(.container)) { tag_name <- .container .container <- function(...) { - htmltools::tags[[tag_name]](..., "aria-live" = "polite") + htmltools::tag(tag_name, list(..., "aria-live" = "polite")) } } diff --git a/R/transformers.R b/R/transformers.R index 65aef91f..29b74525 100644 --- a/R/transformers.R +++ b/R/transformers.R @@ -157,7 +157,6 @@ epoxy_transform_set <- function( if (length(inlines)) { for (eng in engine) { - # TODO: make it possible to reset inline transformer settings .globals[["inline"]][[eng]] <- purrr::list_assign(.globals[["inline"]][[eng]], !!!inlines) } diff --git a/R/utils-knitr.R b/R/utils-knitr.R index a9120d3b..8ba21e1b 100644 --- a/R/utils-knitr.R +++ b/R/utils-knitr.R @@ -1,5 +1,5 @@ knitr_current_label <- function() { - if (isTRUE(knitr::opts_current$get("...inline_chunk"))) { + if (isTRUE(.globals$inline_chunk)) { return("___inline_chunk___") } @@ -37,13 +37,17 @@ knitr_chunk_specific_options <- function(label = knitr_current_label()) { # previous chunk -- or at least `opts_current` returns the previous chunk's # options. This inline chunk detector could probably be built into knitr in some # way: https://github.com/yihui/knitr/issues/1988 +# Prior to knitr 1.44 we could use `opts_current$set()` to set an inline chunk +# option, but modifying the current chunk options will now throw an error, +# see: https://github.com/yihui/knitr/issues/1798 # nocov start knitr_register_detect_inline <- function() { if ("...detect_inline_chunk" %in% knitr::opts_chunk$get()) { + # We've already registered the global option we hook into return() } - # We key off this chunk to always set inline chunk status + # We key off this chunk options to always set inline chunk status knitr::opts_chunk$set(...detect_inline_chunks = TRUE) # Set `...inline_chunk` chunk option to FALSE when entering any @@ -56,10 +60,10 @@ knitr_register_detect_inline <- function() { knitr_hook_detect_inline_chunk <- function(before, ...) { # Set to FALSE inside a code chunk, reset to TRUE outside - knitr::opts_current$set(...inline_chunk = !before) + .globals$inline_chunk <- !before } knitr_is_inline_chunk <- function() { - knitr::opts_current$get("...inline_chunk") %||% + .globals$inline_chunk %||% is.null(knitr::opts_current$get("label")) } diff --git a/cran-comments.md b/cran-comments.md index 38fae6d7..20257fd0 100644 --- a/cran-comments.md +++ b/cran-comments.md @@ -1,7 +1,3 @@ ## R CMD check results 0 errors | 0 warnings | 0 note - -This is a small patch release to fix -a few critical issues that were discovered -after the last CRAN release. diff --git a/inst/examples/ui_epoxy_html/app.R b/inst/examples/ui_epoxy_html/app.R index d7774fc6..08f7faf3 100644 --- a/inst/examples/ui_epoxy_html/app.R +++ b/inst/examples/ui_epoxy_html/app.R @@ -6,7 +6,7 @@ ui <- fluidPage( h2("ui_epoxy_html demo"), ui_epoxy_html( .id = "example", - .class_item = "inner", + .item_class = "inner", fluidRow( tags$div( class = "col-xs-4", diff --git a/inst/examples/ui_epoxy_mustache/app.R b/inst/examples/ui_epoxy_mustache/app.R index ada82659..7a0913b9 100644 --- a/inst/examples/ui_epoxy_mustache/app.R +++ b/inst/examples/ui_epoxy_mustache/app.R @@ -34,6 +34,12 @@ server <- function(input, output, session) { }) favorites <- reactive({ + if (identical(input$fruits, "123456")) { + # Errors are equivalent to "empty" values, + # the rest of the template will still render. + stop("Bad fruits, bad!") + } + if (!nzchar(input$fruits)) return(NULL) list(fruits = strsplit(input$fruits, "\\s*,\\s*")[[1]]) }) diff --git a/inst/srcjs/output-epoxy-mustache.js b/inst/srcjs/output-epoxy-mustache.js index 73167571..1407a7a9 100644 --- a/inst/srcjs/output-epoxy-mustache.js +++ b/inst/srcjs/output-epoxy-mustache.js @@ -1,58 +1,103 @@ /* globals Shiny,$,Mustache,CustomEvent */ +class EpoxyMustache extends window.HTMLElement { + static is_set_global_event_listener = false -const epoxyMustacheOutputBinding = new Shiny.OutputBinding() - -$.extend(epoxyMustacheOutputBinding, { - find: function (scope) { - return $(scope).find('.epoxy-mustache') - }, - renderValue: function (el, data) { - if (!el.epoxyTemplate) { - // store template in DOM element and clean up visible markup - el.epoxyTemplate = el.dataset.epoxyTemplate - el.removeAttribute('data-epoxy-template') - } + constructor () { + super() - const errors = data.__errors__ - if (errors && errors.length > 0) { - errors.forEach(key => { - console.error(`[epoxy] [${el.id}]: ${data[key]}`) - el.dispatchEvent( - new CustomEvent('epoxy-error', { - bubbles: true, - detail: { - output: el.id, - key, - message: data[key], - outputType: 'mustache' - } - }) + if (EpoxyMustache.is_set_global_event_listener) return + window.addEventListener('epoxy-message.mustache', ev => { + // {example: {thing: "dolphin", color: "blue", height: 5}} + EpoxyMustache.update_all(ev.detail) + }) + EpoxyMustache.is_set_global_event_listener = true + } + + static update_all (data) { + // { [id]: template_data } + for (const [key, value] of Object.entries(data)) { + const el = document.getElementById(key) + if (!el) { + console.warn( + `[epoxy-mustache] No element with id "${key}"`, { [key]: value } ) - data[key] = '' - }) + continue + } + el.update(value) } + } + + connectedCallback () { + // store template in DOM element and clean up visible markup + this.mustache_template = this.dataset.epoxyTemplate + this.removeAttribute('data-epoxy-template') + } + + _emit_errors (data) { + const errors = data.__errors__ + if (!errors) return + if (errors.length === 0) return - el.innerHTML = Mustache.render(el.epoxyTemplate, data) - el.dispatchEvent( - new CustomEvent('epoxy-update', { + errors.forEach(key => { + console.error(`[epoxy-mustache] [${this.id}]: ${data[key]}`) + this.dispatchEvent( + new CustomEvent('epoxy-errored', { + bubbles: true, + detail: { + output: this.id, + key, + message: data[key], + outputType: 'mustache' + } + }) + ) + data[key] = '' + }) + } + + _emit_updated (data) { + this.dispatchEvent( + new CustomEvent('epoxy-updated', { bubbles: true, - detail: { output: el.id, data, outputType: 'mustache' } + detail: { output: this.id, data, outputType: 'mustache' } }) ) - }, - renderError: function (el, err) { - this.clearError(el) - if (err.message !== '') { - console.error('[epoxy] ' + err.message) - el.classList.add('epoxy-error') - } - }, - clearError: function (el) { - el.classList.remove('epoxy-error') } -}) -Shiny.outputBindings.register( - epoxyMustacheOutputBinding, - 'shiny.ui_epoxy_mustache' -) + update (data) { + this._emit_errors(data) + + this.innerHTML = Mustache.render(this.mustache_template, data) + this._emit_updated(data) + } +} + +window.customElements.define('epoxy-mustache', EpoxyMustache) + +if (window.Shiny) { + const epoxyMustacheOutputBinding = new Shiny.OutputBinding() + + $.extend(epoxyMustacheOutputBinding, { + find: function (scope) { + return $(scope).find('.epoxy-mustache') + }, + renderValue: function (el, data) { + el.update(data) + }, + renderError: function (el, err) { + this.clearError(el) + if (err.message !== '') { + console.error(`[epoxy-mustache] [${el.id}] ${err.message}`) + el.classList.add('epoxy-error') + } + }, + clearError: function (el) { + el.classList.remove('epoxy-error') + } + }) + + Shiny.outputBindings.register( + epoxyMustacheOutputBinding, + 'shiny.ui_epoxy_mustache' + ) +} diff --git a/inst/srcjs/output-epoxy.js b/inst/srcjs/output-epoxy.js index 7c231367..404bed9b 100644 --- a/inst/srcjs/output-epoxy.js +++ b/inst/srcjs/output-epoxy.js @@ -1,12 +1,45 @@ /* globals Shiny,$,CustomEvent */ -const epoxyOutputBinding = new Shiny.OutputBinding() +class EpoxyHTML extends window.HTMLElement { + static is_set_global_event_listener = false -$.extend(epoxyOutputBinding, { - find: function (scope) { - return $(scope).find('.epoxy-html') - }, - _is_empty: function (x) { + last = null + + constructor () { + super() + + if (EpoxyHTML.is_set_global_event_listener) return + + window.addEventListener('epoxy-message.html', ev => { + // {example: {thing: "dolphin", color: "blue", height: 5}} + EpoxyHTML.update_all(ev.detail) + }) + EpoxyHTML.is_set_global_event_listener = true + } + + static update_all (data, partial = false) { + // { [id]: { [itemKey]: value }} + // { example: { thing: "dolphin", color: "blue", height: 5 }} + if (partial) { + for (const key of Object.keys(data)) { + data[key].__partial = true + } + } + + for (const [key, value] of Object.entries(data)) { + const el = document.getElementById(key) + if (!el) { + console.warn( + `[epoxy-html] [${key}] No element found with id`, { [key]: value } + ) + continue + } + el.update(value) + } + } + + /* ---- Private methods ---- */ + _is_empty (x) { if (x === undefined || x === null) return true if (typeof x === 'number') return false if (typeof x === 'string') return false @@ -14,8 +47,9 @@ $.extend(epoxyOutputBinding, { if (Array.isArray(x) && x.length) return false if (x instanceof Object && Object.keys(x).length) return false return true - }, - _deepEqual (x, y) { + } + + _deep_equal (x, y) { if (x === y) { return true } @@ -37,78 +71,96 @@ $.extend(epoxyOutputBinding, { } for (const key of keysX) { - if (!keysY.includes(key) || !this._deepEqual(x[key], y[key])) { + if (!keysY.includes(key) || !this._deep_equal(x[key], y[key])) { return false } } return true - }, - _last: null, - renderValue: function (el, data) { - const outputId = el.id + } - const items = el.querySelectorAll('[data-epoxy-item]') - items.forEach(item => { - item.classList.remove('epoxy-item__placeholder') - const itemName = item.dataset.epoxyItem - const asHTML = item.dataset.epoxyAsHtml === 'true' - - const evData = { output: outputId, name: itemName, outputType: 'html' } - - const updateContents = (el, contents) => { - asHTML ? (el.innerHTML = contents) : (el.textContent = contents) - el.dispatchEvent( - new CustomEvent('epoxy-update', { - bubbles: true, - detail: { ...evData, value: contents } - }) - ) - return el - } + _remove_item_copies (item) { + const itemKey = item.dataset.epoxyItem + this.querySelectorAll(`[data-epoxy-copy="${itemKey}"]`).forEach(item => + item.parentElement.removeChild(item) + ) + } - // remove copies of epoxyItem (the first item is the pattern) - const removeCopies = () => { - el - .querySelectorAll(`[data-epoxy-copy="${itemName}"]`) - .forEach(item => item.parentElement.removeChild(item)) + _event_updated (key, data) { + return new CustomEvent('epoxy-updated', { + bubbles: true, + detail: { output: this.id, key, data, outputType: 'html' } + }) + } + + _event_errored (key, data) { + console.error(`[epoxy-html] [${this.id}]: ${data}`) + + return new CustomEvent('epoxy-errored', { + bubbles: true, + detail: { + output: this.id, + key, + message: data, + outputType: 'html' } + }) + } - let itemData = data[itemName] + error_classes = ['epoxy-item__error', 'hint--top-right', 'hint--error'] - const errorClasses = ['epoxy-item__error', 'hint--top-right', 'hint--error'] + _item_clear_error (item) { + this.error_classes.forEach(c => item.classList.remove(c)) + item.removeAttribute('aria-label') + } - if (data.__errors__ && data.__errors__.includes(itemName)) { - errorClasses.forEach(c => item.classList.add(c)) - removeCopies() - updateContents(item, item.dataset.epoxyPlaceholder || '') - item.style.removeProperty('display') - item.setAttribute('aria-label', itemData) - item.dispatchEvent( - new CustomEvent('epoxy-error', { - bubbles: true, - detail: { - output: el.id, - key: itemName, - message: itemData, - outputType: 'html' - } - }) - ) + _item_show_error (item, data) { + const itemKey = item.dataset.epoxyItem + this.error_classes.forEach(c => item.classList.add(c)) + this._remove_item_copies(item) + + this._item_update_contents(item, item.dataset.epoxyPlaceholder || '') + item.style.removeProperty('display') + item.setAttribute('aria-label', data) + item.dispatchEvent(this._event_errored(itemKey, data)) + } + + _item_update_contents (item, contents) { + const asHTML = item.dataset.epoxyAsHtml === 'true' + + asHTML ? (item.innerHTML = contents) : (item.textContent = contents) + return item + } + + update (data) { + const items = this.querySelectorAll('[data-epoxy-item]') + + items.forEach(item => { + item.classList.remove('epoxy-item__placeholder') + const itemKey = item.dataset.epoxyItem + + let itemData = data[itemKey] + + if (data.__errors__ && data.__errors__.includes(itemKey)) { + this._item_show_error(item, itemData) return } else { - errorClasses.forEach(c => item.classList.remove(c)) - item.removeAttribute('aria-label') + this._item_clear_error(item) } - if (this._last && this._deepEqual(itemData, this._last[itemName])) { + if (this._last && this._deep_equal(itemData, this._last[itemKey])) { // don't do anything, the value hasn't changed return } - removeCopies() + this._remove_item_copies(item) if (this._is_empty(itemData)) { + if (data.__partial) { + // This is partial update, so the empty value is ignored. + data[itemKey] = this._last[itemKey] + return + } item.style.display = 'none' return } else { @@ -116,37 +168,59 @@ $.extend(epoxyOutputBinding, { } if (!(itemData instanceof Array)) { - updateContents(item, itemData) + this._item_update_contents(item, itemData) + item.dispatchEvent(this._event_updated(itemKey, itemData)) return } // If an array, use the initial item as a pattern - updateContents(item, itemData[0]) + const itemEventUpdated = this._event_updated(itemKey, itemData) + itemEventUpdated.detail.copies = [] const itemParent = item.parentElement + + this._item_update_contents(item, itemData[0]) itemData = itemData.slice(1) for (const itemDataThis of itemData) { const itemNew = item.cloneNode() itemNew.removeAttribute('data-epoxy-item') - itemNew.dataset.epoxyCopy = itemName + itemNew.dataset.epoxyCopy = itemKey itemParent.insertAdjacentElement('beforeend', itemNew) - updateContents(itemNew, itemDataThis) + this._item_update_contents(itemNew, itemDataThis) + itemEventUpdated.detail.copies.push(itemNew) } + + item.dispatchEvent(itemEventUpdated) }) this._last = data - el.classList.remove('epoxy-init') - }, - renderError: function (el, err) { - this.clearError(el) - if (err.message !== '') { - console.error('[epoxy] ' + err.message) - el.classList.add('epoxy-error') - } - }, - clearError: function (el) { - el.classList.remove('epoxy-error') + this.classList.remove('epoxy-init') } -}) +} + +window.customElements.define('epoxy-html', EpoxyHTML) + +if (window.Shiny) { + const epoxyOutputBinding = new Shiny.OutputBinding() + + $.extend(epoxyOutputBinding, { + find: function (scope) { + return $(scope).find('epoxy-html') + }, + renderValue: function (el, data) { + el.update(data) + }, + renderError: function (el, err) { + this.clearError(el) + if (err.message !== '') { + console.error(`[epoxy-html] [${el.id}] ${err.message}`) + el.classList.add('epoxy-error') + } + }, + clearError: function (el) { + el.classList.remove('epoxy-error') + } + }) -Shiny.outputBindings.register(epoxyOutputBinding, 'shiny.ui_epoxy_html') + Shiny.outputBindings.register(epoxyOutputBinding, 'shiny.ui_epoxy_html') +} diff --git a/man/ui_epoxy_html.Rd b/man/ui_epoxy_html.Rd index f77b8c14..54504933 100644 --- a/man/ui_epoxy_html.Rd +++ b/man/ui_epoxy_html.Rd @@ -9,9 +9,9 @@ ui_epoxy_html( .id, ..., .class = NULL, - .class_item = NULL, - .container = "div", - .container_item = "span", + .style = NULL, + .item_tag = "span", + .item_class = NULL, .placeholder = "", .sep = "", .open = "{{", @@ -21,7 +21,10 @@ ui_epoxy_html( .literal = FALSE, .trim = FALSE, .aria_live = c("polite", "off", "assertive"), - .aria_atomic = TRUE + .aria_atomic = TRUE, + .class_item = deprecated(), + .container = deprecated(), + .container_item = deprecated() ) epoxyHTML(.id, ...) @@ -32,16 +35,12 @@ epoxyHTML(.id, ...) \item{...}{UI elements or text (that will be treated as HTML), containing template variables. Use named values to provide initial placeholder values.} -\item{.class}{Classes added to the output div, in addition to \code{.epoxy-html}} +\item{.class, .style}{Classes and inline style directives added to the +\verb{} container into which the elements in \code{...} are placed.} -\item{.class_item}{Classes added to the \code{.container} wrapping each template -variable.} - -\item{.container}{The name of the HTML element to be used for the output -element, by default \code{"div"}.} - -\item{.container_item}{The name of the HTML element to be used for each -template item, by default \code{"span"}.} +\item{.item_tag, .item_class}{The HTML element tag name and classes used to +wrap each template variable. By default, each template is wrapped in a +\verb{}.} \item{.placeholder}{Default placeholder if a template variable placeholder isn't provided.} @@ -91,6 +90,15 @@ of the values within change. In other words, set \code{.aria_live = "off"} and containers of each region in the app that receives updates. \code{ui_epoxy_html()} does targeted updates, changing only the parts of the UI that have changed.} + +\item{.class_item}{\ifelse{html}{\href{https://lifecycle.r-lib.org/articles/stages.html#deprecated}{\figure{lifecycle-deprecated.svg}{options: alt='[Deprecated]'}}}{\strong{[Deprecated]}} Deprecated in +\pkg{epoxy} v1.0.0, please use \code{.item_class} instead.} + +\item{.container}{\ifelse{html}{\href{https://lifecycle.r-lib.org/articles/stages.html#deprecated}{\figure{lifecycle-deprecated.svg}{options: alt='[Deprecated]'}}}{\strong{[Deprecated]}} Deprecated in +\pkg{epoxy} v1.0.0, where the container is now always \verb{}.} + +\item{.container_item}{\ifelse{html}{\href{https://lifecycle.r-lib.org/articles/stages.html#deprecated}{\figure{lifecycle-deprecated.svg}{options: alt='[Deprecated]'}}}{\strong{[Deprecated]}} Deprecated in +\pkg{epoxy} v1.0.0, please use \code{.item_tag} instead.} } \value{ An HTML object. @@ -146,7 +154,7 @@ ui <- fluidPage( h2("ui_epoxy_html demo"), ui_epoxy_html( .id = "example", - .class_item = "inner", + .item_class = "inner", fluidRow( tags$div( class = "col-xs-4", diff --git a/man/ui_epoxy_markdown.Rd b/man/ui_epoxy_markdown.Rd index 52a3e45c..ab5c8f62 100644 --- a/man/ui_epoxy_markdown.Rd +++ b/man/ui_epoxy_markdown.Rd @@ -10,9 +10,9 @@ ui_epoxy_markdown( .markdown_fn = NULL, .markdown_args = list(), .class = NULL, - .class_item = NULL, - .container = "div", - .container_item = "span", + .style = NULL, + .item_tag = "span", + .item_class = NULL, .placeholder = "", .sep = "", .open = "{{", @@ -22,7 +22,10 @@ ui_epoxy_markdown( .literal = FALSE, .trim = FALSE, .aria_live = c("polite", "off", "assertive"), - .aria_atomic = TRUE + .aria_atomic = TRUE, + .class_item = deprecated(), + .container = deprecated(), + .container_item = deprecated() ) } \arguments{ @@ -40,16 +43,12 @@ otherwise we use \code{\link[commonmark:commonmark]{commonmark::markdown_html()} \item{.markdown_args}{A list of arguments to pass to \code{\link[commonmark:commonmark]{commonmark::markdown_html()}}.} -\item{.class}{Classes added to the output div, in addition to \code{.epoxy-html}} +\item{.class, .style}{Classes and inline style directives added to the +\verb{} container into which the elements in \code{...} are placed.} -\item{.class_item}{Classes added to the \code{.container} wrapping each template -variable.} - -\item{.container}{The name of the HTML element to be used for the output -element, by default \code{"div"}.} - -\item{.container_item}{The name of the HTML element to be used for each -template item, by default \code{"span"}.} +\item{.item_tag, .item_class}{The HTML element tag name and classes used to +wrap each template variable. By default, each template is wrapped in a +\verb{}.} \item{.placeholder}{Default placeholder if a template variable placeholder isn't provided.} @@ -99,6 +98,15 @@ of the values within change. In other words, set \code{.aria_live = "off"} and containers of each region in the app that receives updates. \code{ui_epoxy_html()} does targeted updates, changing only the parts of the UI that have changed.} + +\item{.class_item}{\ifelse{html}{\href{https://lifecycle.r-lib.org/articles/stages.html#deprecated}{\figure{lifecycle-deprecated.svg}{options: alt='[Deprecated]'}}}{\strong{[Deprecated]}} Deprecated in +\pkg{epoxy} v1.0.0, please use \code{.item_class} instead.} + +\item{.container}{\ifelse{html}{\href{https://lifecycle.r-lib.org/articles/stages.html#deprecated}{\figure{lifecycle-deprecated.svg}{options: alt='[Deprecated]'}}}{\strong{[Deprecated]}} Deprecated in +\pkg{epoxy} v1.0.0, where the container is now always \verb{}.} + +\item{.container_item}{\ifelse{html}{\href{https://lifecycle.r-lib.org/articles/stages.html#deprecated}{\figure{lifecycle-deprecated.svg}{options: alt='[Deprecated]'}}}{\strong{[Deprecated]}} Deprecated in +\pkg{epoxy} v1.0.0, please use \code{.item_tag} instead.} } \value{ An HTML object. @@ -111,7 +119,7 @@ markdown to HTML using \code{\link[pandoc:pandoc_convert]{pandoc::pandoc_convert available) or \code{\link[commonmark:commonmark]{commonmark::markdown_html()}} otherwise. } \examples{ -\dontshow{if (rlang::is_installed("shiny")) (if (getRversion() >= "3.4") withAutoprint else force)(\{ # examplesIf} +\dontshow{if (rlang::is_installed("shiny") && rlang::is_interactive()) (if (getRversion() >= "3.4") withAutoprint else force)(\{ # examplesIf} library(shiny) # Shiny epoxy template functions don't support inline transformations, diff --git a/man/ui_epoxy_mustache.Rd b/man/ui_epoxy_mustache.Rd index 05674c26..7e645c59 100644 --- a/man/ui_epoxy_mustache.Rd +++ b/man/ui_epoxy_mustache.Rd @@ -5,9 +5,21 @@ \alias{ui_epoxy_whisker} \title{Epoxy HTML Mustache Template} \usage{ -ui_epoxy_mustache(id, ..., .file = NULL, .sep = "", .container = "div") +ui_epoxy_mustache( + id, + ..., + .file = NULL, + .sep = "", + .container = "epoxy-mustache" +) -ui_epoxy_whisker(id, ..., .file = NULL, .sep = "", .container = "div") +ui_epoxy_whisker( + id, + ..., + .file = NULL, + .sep = "", + .container = "epoxy-mustache" +) } \arguments{ \item{id}{The ID of the output.} @@ -80,6 +92,12 @@ server <- function(input, output, session) { }) favorites <- reactive({ + if (identical(input$fruits, "123456")) { + # Errors are equivalent to "empty" values, + # the rest of the template will still render. + stop("Bad fruits, bad!") + } + if (!nzchar(input$fruits)) return(NULL) list(fruits = strsplit(input$fruits, "\\\\s*,\\\\s*")[[1]]) }) diff --git a/tests/testthat/apps/epoxy-html-list/app.R b/tests/testthat/apps/epoxy-html-list/app.R new file mode 100644 index 00000000..43c131bc --- /dev/null +++ b/tests/testthat/apps/epoxy-html-list/app.R @@ -0,0 +1,27 @@ +library(shiny) +library(epoxy) + +ui <- fixedPage( + sliderInput("n", "Letters", 1, 26, 3), + p( + "data-test-id" = "desc", + ui_epoxy_html("desc", "You've picked {{n}} {{thing}}:") + ), + tags$ul( + "data-test-id" = "list", + ui_epoxy_html("list", "{{item}}", .item_tag = "li") + ) +) + +server <- function(input, output, session) { + output$list <- render_epoxy( + item = letters[1:input$n] + ) + + output$desc <- render_epoxy( + n = input$n, + thing = if (input$n == 1) "letter" else "letters" + ) +} + +shinyApp(ui, server) diff --git a/tests/testthat/apps/no-shiny/app.R b/tests/testthat/apps/no-shiny/app.R new file mode 100644 index 00000000..ef387aa3 --- /dev/null +++ b/tests/testthat/apps/no-shiny/app.R @@ -0,0 +1,18 @@ +library(shiny) +library(epoxy) + +ui <- fixedPage( + textInput("first", "First Name", "John"), + textInput("last", "Last Name", "Doe"), + ui_epoxy_html( + "hello", + p("Hello, {{first}} {{last}}!", "data-test-id" = "text") + ), + includeScript("epoxy-no-shiny.js") +) + +server <- function(input, output, session) { + +} + +shinyApp(ui, server) diff --git a/tests/testthat/apps/no-shiny/epoxy-no-shiny.js b/tests/testthat/apps/no-shiny/epoxy-no-shiny.js new file mode 100644 index 00000000..12177735 --- /dev/null +++ b/tests/testthat/apps/no-shiny/epoxy-no-shiny.js @@ -0,0 +1,31 @@ +/* global EpoxyHTML */ +function sendUpdatesToEpoxy (id) { + return (event, value) => { + if (typeof value === 'undefined') { + value = { [event.target.id]: event.target.value } + } + if (typeof value === 'function') { + value = value(event) + } + + const data = { [id]: value } + EpoxyHTML.update_all(data, true) + } +} + +function initApp () { + const initData = { + hello: { + first: document.getElementById('first').value, + last: document.getElementById('last').value + } + } + EpoxyHTML.update_all(initData) + ;['first', 'last'].forEach(inputId => { + document + .getElementById(inputId) + .addEventListener('input', sendUpdatesToEpoxy('hello')) + }) +} + +initApp() diff --git a/tests/testthat/helpers.R b/tests/testthat/helpers.R index 709d43c4..36e3191a 100644 --- a/tests/testthat/helpers.R +++ b/tests/testthat/helpers.R @@ -4,6 +4,10 @@ render_rmd <- function( output_format = rmarkdown::md_document(), envir = new.env() ) { + if (identical(Sys.getenv("TESTTHAT"), "true")) { + skip_if_not(rmarkdown::pandoc_available("1.12.3")) + } + if (length(rmd_text) == 1 && !grepl("\n", rmd_text)) { if (file.exists(rmd_text)) { rmd_text <- readLines(rmd_text) diff --git a/tests/testthat/rmds/use-chunk_chunk-opts.Rmd b/tests/testthat/rmds/use-chunk_chunk-opts.Rmd index 47e92f46..e26f241d 100644 --- a/tests/testthat/rmds/use-chunk_chunk-opts.Rmd +++ b/tests/testthat/rmds/use-chunk_chunk-opts.Rmd @@ -13,14 +13,7 @@ the_data <- list( list(first = "nine", second = "ten") ) -knitr::opts_chunk$set(.data = the_data[[1]], ...detect_inline_chunks = TRUE) -knitr::knit_hooks$set(...detect_inline_chunks = function(before, options) { - if (before) { - knitr::opts_current$set(...inline_chunk = FALSE) - } else { - knitr::opts_current$set(...inline_chunk = TRUE) - } -}) +knitr::opts_chunk$set(.data = the_data[[1]]) ``` ```{epoxy chunk-template, .data = the_data[[2]]} diff --git a/tests/testthat/rmds/use-file_example-no-yaml.md b/tests/testthat/rmds/use-file_example-no-yaml.md new file mode 100644 index 00000000..7e4d7728 --- /dev/null +++ b/tests/testthat/rmds/use-file_example-no-yaml.md @@ -0,0 +1 @@ +{one}, {two}, {three}, {four} diff --git a/tests/testthat/test-epoxy_use.R b/tests/testthat/test-epoxy_use.R index ad6d8808..88d1329d 100644 --- a/tests/testthat/test-epoxy_use.R +++ b/tests/testthat/test-epoxy_use.R @@ -177,7 +177,50 @@ describe("epoxy_use_file()", { ) }) + it("works without a yaml header", { + template_no_header <- test_path("rmds", "use-file_example-no-yaml.md") + + expect_equal( + epoxy_use_file( + .data = list( + one = "first", + two = "second", + three = "third", + four = "fourth" + ), + file = template_no_header + ), + knitr::asis_output("first, second, third, fourth") + ) + }) + it("errors when the file doesn't exist", { expect_error(epoxy_use_file(file = "bad-file.md")) }) + + it("errors when the file is essentially empty", { + tmpfile <- tempfile(fileext = ".md") + on.exit(unlink(tmpfile)) + + writeLines(c("", " ", "\t\t", ""), tmpfile) + expect_error(epoxy_use_file(file = tmpfile)) + }) + + it("allows the user to set the engine from the fn call", { + tmpl <- test_path("rmds", "use-file_html.md") + expect_equal( + epoxy_use_file(file = tmpl, engine = "epoxy"), + knitr::asis_output( + gsub( + "}}", "}", + gsub( + "{{", "{", + read_body_without_yaml(tmpl), + fixed = TRUE + ), + fixed = TRUE + ) + ) + ) + }) }) diff --git a/tests/testthat/test-shiny.R b/tests/testthat/test-shiny.R index c52e8961..49e6ead2 100644 --- a/tests/testthat/test-shiny.R +++ b/tests/testthat/test-shiny.R @@ -33,20 +33,45 @@ describe("ui_epoxy_html()", { expect_true(grepl("placeholder", format(ex2), fixed = TRUE)) }) - it (".container and .container_item", { + it (".item_container", { div_span <- ui_epoxy_html("test", "{{item}}") expect_s3_class(div_span, "shiny.tag") - expect_equal(div_span$name, "div") + expect_equal(div_span$name, "epoxy-html") expect_true(grepl("^ { + console.log(ev) + Shiny.setInputValue('epoxy_updated_list', ev.detail); + }) + document.getElementById('desc').addEventListener('epoxy-updated', ev => { + console.log(ev) + Shiny.setInputValue('epoxy_updated_desc_' + ev.detail.key, ev.detail); + }) + ") + + expect_event <- function(input, ...) { + data <- list(...) + expect_equal( + app$get_values(input = !!input)$input[[!!input]], + !!data + ) + } + + app$set_inputs(n = 1) + expect_equal( + get_test_element_text("desc"), + "You've picked 1 letter:" + ) + expect_equal( + get_test_element_text("list"), + "a" + ) + expect_event( + "epoxy_updated_list", + output = "list", + key = "item", + data = "a", + outputType = "html" + ) + expect_event( + "epoxy_updated_desc_n", + output = "desc", + key = "n", + data = 1L, + outputType = "html" + ) + expect_event( + "epoxy_updated_desc_thing", + output = "desc", + key = "thing", + data = "letter", + outputType = "html" + ) + + app$set_inputs(n = 4) + expect_equal( + get_test_element_text("desc"), + "You've picked 4 letters:" + ) + expect_equal( + get_test_element_text("list"), + "a\nb\nc\nd" + ) + expect_event( + "epoxy_updated_list", + output = "list", + key = "item", + data = as.list(letters[1:4]), + outputType = "html", + # there are three copies created from the original letter template + # the event has dom elements, shinytest2 only sees an empty named list + copies = lapply(1:3, function(...) list(a = 1)[0]) + ) + expect_event( + "epoxy_updated_desc_n", + output = "desc", + key = "n", + data = 4L, + outputType = "html" + ) + expect_event( + "epoxy_updated_desc_thing", + output = "desc", + key = "thing", + data = "letters", + outputType = "html" + ) +}) diff --git a/tests/testthat/test-shiny_ui_epoxy_html-no-shiny.R b/tests/testthat/test-shiny_ui_epoxy_html-no-shiny.R new file mode 100644 index 00000000..09183640 --- /dev/null +++ b/tests/testthat/test-shiny_ui_epoxy_html-no-shiny.R @@ -0,0 +1,57 @@ +skip_on_cran() +skip_if_not_installed("chromote") +skip_if_not_installed("shinytest2") +library(shinytest2) + +test_that("ui_epoxy_html() can be used without shiny", { + app <- AppDriver$new( + app_dir = test_path("apps", "no-shiny"), + name = "no-shiny", + height = 500, + width = 700, + view = interactive(), + expect_values_screenshot_args = FALSE + ) + on.exit(app$stop()) + + get_test_element_text <- function(id) { + app$get_js( + sprintf( + "document.querySelector('[data-test-id=\"%s\"]').innerText", + id + ) + ) + } + + chrome <- app$get_chromote_session() + + update_input <- function(id, text) { + inputs <- structure(list(text), names = id) + app$set_inputs(!!!inputs) + # In real life, the user's interaction would trigger this event + app$run_js(sprintf( + "document.getElementById('%s').dispatchEvent(new Event('input', { bubbles: true }))", + id + )) + } + + expect_epoxy_text <- function() { + inputs <- app$get_values(input = c("first", "last"))$input + + expect_equal( + get_test_element_text("text"), + paste0("Hello, ", inputs$first, " ", inputs$last, "!") + ) + } + + expect_epoxy_text() + + update_input("first", "Jane") + expect_epoxy_text() + + update_input("last", "Diamonds") + expect_epoxy_text() + + update_input("first", "Lucy") + expect_epoxy_text() +})