1
2knit_params_get <- function(input_lines, params) {
3
4  # read the default parameters and extract them into a named list
5  knit_params <- knitr::knit_params(input_lines)
6  if (packageVersion('yaml') < '2.1.14') knit_params <- mark_utf8(knit_params)
7  default_params <- list()
8  for (param in knit_params) {
9    default_params[param$name] <- list(param$value)
10  }
11
12  # validate params passed to render
13  if (!is.null(params)) {
14
15    if (identical(params, "ask")) {
16      params <- knit_params_ask(
17        input_lines = input_lines, shiny_args = list(launch.browser = TRUE)
18      )
19      if (is.null(params)) {
20        stop("render parameter configuration canceled")
21      }
22    }
23
24    # verify they are a list
25    if (!is.list(params) || (length(names(params)) != length(params))) {
26      stop("render params argument must be a named list")
27    }
28
29    # verify that all parameters passed are also in the yaml
30    invalid_params <- setdiff(names(params), names(default_params))
31    if (length(invalid_params) > 0) {
32      stop("render params not declared in YAML: ",
33           paste(invalid_params, collapse = ", "))
34    }
35  }
36
37  # merge explicitly provided params with defaults
38  merge_lists(default_params, params, recursive = FALSE)
39}
40
41params_label <- function(inputControlFn, param) {
42  label <- ifelse(is.null(param$label), param$name, param$label)
43  if (identical(inputControlFn, shiny::fileInput)) {
44    if (is.character(param$value)) {
45      label <- paste0(label, " (default: ", param$value, ")")
46    }
47  }
48  label
49}
50
51params_value_to_ui <- function(inputControlFn, value, showDefault) {
52  if (is.null(showDefault)) {
53    showDefault <- TRUE
54  }
55
56  isNumericInput <- identical(inputControlFn, shiny::numericInput) ||
57    identical(inputControlFn, shiny::sliderInput)
58
59  if (identical(inputControlFn, shiny::fileInput)) {
60    NULL
61  } else if (identical(inputControlFn, shiny::textInput)) {
62    ## TODO: if long input, maybe truncate textInput values for display
63
64    if (showDefault) {
65      classes <- class(value)
66      if ("POSIXct" %in% classes) {
67        as.character(value)
68      } else {
69        value
70      }
71    } else {
72      NULL
73    }
74  } else if (is.null(value)) {
75    # The numerics can't deal with a NULL value, but everything else is fine.
76    if (isNumericInput) {
77      0
78    } else {
79      value
80    }
81  } else {
82    if (showDefault) {
83      ## A type/control that doesn't need special handling; just emit the value.
84      value
85    } else {
86      if (isNumericInput) {
87        0
88      } else if (identical(inputControlFn, shiny::dateInput)) {
89        # Use NA to clear date inputs:
90        # https://github.com/rstudio/shiny/pull/1299
91        NA
92      } else if (identical(inputControlFn, shiny::radioButtons)) {
93        # As suggested in ?radioButtons
94        character(0)
95      } else {
96        NULL
97      }
98    }
99  }
100}
101
102params_value_from_ui <- function(inputControlFn, value, uivalue) {
103  if (identical(inputControlFn, shiny::fileInput)) {
104    backup_file_input(uivalue$datapath)
105  } else if (identical(inputControlFn, shiny::textInput)) {
106    classes <- class(value)
107    if ("POSIXct" %in% classes) {
108      if (identical(uivalue, "")) {
109        # show_default: false produces this situation
110        # Empty POSIXct
111        Sys.time()[-1]
112      } else {
113        as.POSIXct(uivalue)
114      }
115    } else {
116      uivalue
117    }
118  } else {
119    ## A type/control that doesn't need special handling; just emit the value.
120    uivalue
121  }
122}
123
124# Uploaded files will be deleted when the shiny UI is closed, so we need to back
125# them up to new temp files: https://github.com/rstudio/rmarkdown/issues/919
126backup_file_input <- function(files) {
127  files2 <- files
128  for (i in seq_along(files)) {
129    dir.create(d <- tempfile())
130    files2[i] <- file.path(d, basename(files[i]))
131  }
132  file.copy(files, files2)
133  files2
134}
135
136params_get_input <- function(param) {
137  # Maps between value types and input: XXX
138  default_inputs <- list(
139      logical = "checkbox",
140      Date = "date",
141      ## BUG: shiny does not support datetime selectors
142      ##     https://github.com/rstudio/shiny/issues/897
143      ##     we ask for string input for now.
144      POSIXct = "datetime",
145      character = "text"
146      )
147  default_inputs$integer <- default_inputs$numeric <-  {
148    ## If min/max are specified, use a slider.
149    if (is.null(param$min) || is.null(param$max)) {
150      "numeric"
151    } else {
152      "slider"
153    }
154  }
155
156  input <- param$input
157  if (is.null(input)) {
158    if (!is.null(param$choices)) {
159      ## radio buttons for a small number of choices, select otherwise.
160      if (length(param$choices) <= 4) {
161        input <- "radio"
162      } else {
163        input <- "select"
164      }
165    } else {
166      ## Not choices. Look at the value type to find what input control we
167      ## should use.
168
169      ## A value might have multiple classes. Try: class(Sys.time())
170      ## Try to find first class listed with a named control.
171      for (c in class(param$value)) {
172        default_input <- default_inputs[[c]]
173        if (!is.null(default_input)) {
174          input <- default_input
175          break
176        }
177      }
178    }
179  }
180  input
181}
182
183params_get_control <- function(param) {
184  input <- params_get_input(param)
185  if (is.null(input)) {
186    return(NULL)
187  }
188
189  # Maps between input: XXX and the various Shiny input controls
190  input_controls <- list(
191      checkbox = shiny::checkboxInput,
192      numeric  = shiny::numericInput,
193      slider   = shiny::sliderInput,
194      date     = shiny::dateInput,
195      datetime = shiny::textInput, # placeholder for future datetime picker
196      text     = shiny::textInput,
197      file     = shiny::fileInput,
198      radio    = shiny::radioButtons,
199      select   = shiny::selectInput
200      )
201  control <- input_controls[[input]]
202  if (is.null(control)) {
203    stop(paste("could not determine what control to use for parameter", param$name, "with input:", input))
204  }
205  control
206}
207
208# Returns true if the parameter can be configurable with Shiny UI elements.
209params_configurable <- function(param) {
210  if (is.null(params_get_control(param))) {
211    return(FALSE)                       # no Shiny control
212  }
213  multiple_ok <- (!is.null(param$multiple) && param$multiple)
214  if (multiple_ok) {
215    return(TRUE)
216  }
217  return(length(param$value) <= 1)     # multiple values only when multi-input controls
218}
219
220# Returns a new empty named list.
221params_namedList <- function() {
222  empty <- list()
223  names(empty) <- character()
224  empty
225}
226
227#' Run a shiny application asking for parameter configuration for the given document.
228#'
229#' @param file Path to the R Markdown document with configurable parameters.
230#' @param input_lines Content of the R Markdown document. If \code{NULL}, the contents of \code{file} will be read.
231#' @param params A named list of optional parameter overrides used in place of the document defaults.
232#' @param shiny_args Additional arguments to \code{\link[shiny:runApp]{runApp}}.
233#' @param save_caption Caption to use use for button that saves/confirms parameters.
234#' @param encoding The encoding of the input file; see \code{\link{file}}.
235#'
236#' @return named list with overridden parameter names and value.
237#'
238#' @export
239knit_params_ask <- function(file = NULL,
240                            input_lines = NULL,
241                            params = NULL,
242                            shiny_args = NULL,
243                            save_caption = "Save",
244                            encoding = getOption("encoding")) {
245
246  if (is.null(input_lines)) {
247    if (is.null(file)) {
248      stop("knit_params_ask must have a non-NULL file or input_lines parameter")
249    }
250    input_lines <- read_lines_utf8(file, encoding)
251  }
252
253  knit_params <- knitr::knit_params(input_lines)
254  if (packageVersion('yaml') < '2.1.14') knit_params <- mark_utf8(knit_params)
255
256  ## Input validation on params (checks shared with render)
257  if (!is.null(params)) {
258    ## Must be a named list
259    if (!is.list(params) || (length(names(params)) != length(params))) {
260      stop("knit_params_ask params argument must be a named list")
261    }
262    ## We do not validate names(params) because the document may have changed
263    ## but we're loading parameters that were configured with an older
264    ## version.
265  }
266
267  ## If we happen to not have any knit_params, just return an empty named list
268  ## and don't fire up the Shiny app.
269  if (length(knit_params) == 0) {
270    return(params_namedList())
271  }
272
273  configurable <- Filter(params_configurable, knit_params)
274  unconfigurable <- Filter(Negate(params_configurable), knit_params)
275
276  ## This set of published values is the raw set that came from the user.
277  ## It does not include those values that cannot be configured or are
278  ## left to use the default.
279  values <- params_namedList()
280
281  server <- function(input, output, session) {
282    param.ui <- function(param) {
283      inputControlFn <- params_get_control(param)
284      inputControlFnFormals <- names(formals(inputControlFn))
285
286      inputId <- param$name
287      label <- params_label(inputControlFn, param)
288
289      arguments = list(
290          inputId = inputId,
291          label = label
292          )
293
294      # We MUST process the "value" name even if it is not present (due to
295      # NULL values).
296      attrib_names <- unique(c(names(param), "value"))
297      lapply(attrib_names, function(name) {
298        if (name %in% c("name", "input", "expr")) {
299        } else if (name == "label") {
300          arguments$label <<- label
301        } else if (name == "value") {
302
303          ## The current value for this parameter is either `params` when
304          ## overridden by our caller or `param$value` otherwise.
305          current_value <- param$value
306          if (!is.null(params)) {
307            override <- params[[param$name]]
308            if (!is.null(override)) {
309              current_value <- override
310            }
311          }
312          # Now, transform into something that the input control can handle.
313          current_value <- params_value_to_ui(inputControlFn, current_value,
314                                              param$show_default)
315
316          # value maps to either "value" or "selected" depending on the control.
317          if ("value" %in% inputControlFnFormals) {
318            arguments$value <<- current_value
319          } else if ("selected" %in% inputControlFnFormals) {
320            arguments$selected <<- current_value
321          }
322        } else if (name == "show_default") {
323          # No-op
324        } else {
325          ## Not a special field. Blindly promote to the input control.
326          arguments[[name]] <<- if (inherits(param[[name]], 'knit_param_expr')) {
327            param[[name]][['value']]
328          } else param[[name]]
329        }
330      })
331
332      ## This is based on param$value not current_value because we want to
333      ## understand deviation from the report default, not any (optional)
334      ## call-time override.
335      uidefault <- params_value_to_ui(inputControlFn, param$value, param$show_default)
336      hasDefaultValue <- function(value) {
337        identical(uidefault, value)
338      }
339
340      inputControl <- NULL
341      unsupported <- setdiff(names(arguments), inputControlFnFormals)
342      if (length(unsupported) > 0) {
343        inputControl <- shiny::div(class = "form-group",
344                                   tags$label(class = "control-label",param$name),
345                                   shiny::div(paste('Cannot customize the parameter "', param$name, '" ',
346                                                    'because the "', params_get_input(param), '" ',
347                                                    'Shiny control does not support: ',
348                                                    paste(unsupported, collapse = ', '), sep = '')))
349      } else {
350        inputControl <- do.call(inputControlFn, arguments)
351      }
352
353      showSelectControl <- NULL
354      selectControl <- NULL
355      selectInputId <- paste0("select_", param$name)
356
357      ## Helper to materialize a "default/customize" control.
358      makeSelectControl <- function(default_name, custom_name) {
359        showSelectControl <<- function(current) {
360          (is.null(current) || identical(current, "default"))
361        }
362        hasDefaultValue <<- function(value) { FALSE }
363        choices <- list()
364        choices[[default_name]] <- "default"
365        choices[[custom_name]] <- "custom"
366        selectControl <<- shiny::selectInput(inputId = selectInputId,
367                                             label = label,
368                                             choices = choices)
369      }
370
371      if (is.null(params[[param$name]])) { # prior value; implicit customization
372        ## Dates and times with expressions that mean "now" or "today" are first
373        ## materialized as selects. If the user chooses to customize the field,
374        ## we then show the type-specific picker.
375        if (identical("Sys.time()", param$expr)) {
376          makeSelectControl(paste0("now (", param$value, ")"),
377                            "Use a custom time")
378        } else if (identical("Sys.Date()", param$expr)) {
379          makeSelectControl(paste0("today (", param$value, ")"),
380                            "Use a custom date")
381        } else if (is.null(param$value)) {
382          # fileInput defaults to null, but for other null values, ask the
383          # user to explicitly choose to override (ie. we cannot use value
384          # comparison).
385          if (!identical(inputControlFn, shiny::fileInput)) {
386            makeSelectControl("Unspecified (NULL)",
387                              "Use a custom value")
388          }
389        }
390      }
391
392      output[[paste0("ui_", param$name)]] <- shiny::renderUI({
393        # For most parameters, the selectInputId input will be NULL.
394        if (!is.null(showSelectControl) && showSelectControl(input[[selectInputId]])) {
395          selectControl
396        } else {
397          inputControl
398        }
399      })
400
401      shiny::observe({
402        # A little reactive magic to keep in mind. If you're in one of the
403        # "default/custom" selector scenarios, this will never fire until the
404        # user selects "custom" because the value-producing input control is
405        # not rendered until that point.
406        uivalue <- input[[param$name]]
407        if (is.null(uivalue) || hasDefaultValue(uivalue)) {
408          values[[param$name]] <<- NULL
409        } else {
410          values[[param$name]] <<- params_value_from_ui(inputControlFn, param$value, uivalue)
411        }
412      })
413    }
414
415    lapply(configurable, function(param) {
416      param.ui(param)
417    })
418
419    shiny::observeEvent(input$save, {
420      session$onFlushed(function() {
421        session$close()
422        shiny::stopApp(values)
423      })
424    })
425
426    shiny::observeEvent(input$cancel, {
427      session$onFlushed(function() {
428        session$close()
429        shiny::stopApp(NULL)
430      })
431    })
432  }
433
434  contents <- shiny::tags$div(
435      shiny::fluidRow(shiny::column(12, lapply(configurable, function(param) {
436        shiny::uiOutput(paste0("ui_", param$name))
437      }))), class = "container-fluid")
438
439  if (length(unconfigurable) > 0) {
440    skipped <- shiny::tags$div(shiny::tags$strong("Note:"),
441                            "The following parameters cannot be customized:",
442                            paste(lapply(unconfigurable, function(param) { param$name }), collapse = ", "))
443    contents <- shiny::tagAppendChildren(contents, shiny::fluidRow(shiny::column(12, skipped)))
444  }
445  footer <- shiny::tags$div(
446      shiny::tags$div(
447          shiny::fluidRow(shiny::column(12,
448                                        shiny::actionButton("save", save_caption, class = "btn-primary navbar-btn pull-right"),
449                                        shiny::actionButton("cancel","Cancel", class = "navbar-btn pull-right"))),
450          class = "container-fluid"),
451      class = "navbar navbar-default navbar-fixed-bottom")
452
453  style <- shiny::tags$style(
454      # Our controls are wiiiiide.
455      ".container-fluid .shiny-input-container { width: auto; }",
456      # Prevent the save/cancel buttons from squashing together.
457      ".navbar button { margin-left: 10px; }",
458      # Style for the navbar footer.
459      # http://getbootstrap.com/components/#navbar-fixed-bottom
460      "body { padding-bottom: 70px; }"
461                             )
462  ## Escape is "cancel" and Enter is "save".
463  script <- shiny::tags$script(shiny::HTML("$(document).keyup(function(e) {\n",
464                                           "if (e.which == 13) { $('#save').click(); } // enter\n",
465                                           "if (e.which == 27) { $('#cancel').click(); } // esc\n",
466                                           "});"
467                                           ))
468  ui <- shiny::bootstrapPage(
469      shiny::tags$head(style, script),
470      contents,
471      footer)
472
473  shiny_app <- shiny::shinyApp(ui = ui, server = server)
474  shiny_args <- merge_lists(list(appDir = shiny_app), shiny_args)
475  do.call(shiny::runApp, shiny_args)
476}
477