Skip to content
15 changes: 7 additions & 8 deletions R/Gargle-class.R
Original file line number Diff line number Diff line change
Expand Up @@ -273,14 +273,13 @@ encourage_httpuv <- function() {
if (!is_interactive() || isTRUE(is_installed("httpuv"))) {
return(invisible())
}
local_gargle_verbosity("info")
gargle_info(c(
"The {.pkg httpuv} package enables a nicer Google auth experience, \\
in many cases.",
"It doesn't seem to be installed.",
"Would you like to install it now?"
))
if (utils::menu(c("Yes", "No")) == 1) {
choice <- cli_menu(
"The {.pkg httpuv} package enables a nicer Google auth experience, in many \\
cases, but it isn't installed.",
"Would you like to install it now?",
choices = c("Yes", "No")
)
if (choice == 1) {
utils::install.packages("httpuv")
}
invisible()
Expand Down
34 changes: 20 additions & 14 deletions R/oauth-cache.R
Original file line number Diff line number Diff line change
Expand Up @@ -62,11 +62,13 @@ cache_allowed <- function(path) {
return(FALSE)
}

local_gargle_verbosity("info")
gargle_info("
Is it OK to cache OAuth access credentials in the folder \\
{.path {path}} between R sessions?")
utils::menu(c("Yes", "No")) == 1
choice <- cli_menu(
header = character(),
prompt = "Is it OK to cache OAuth access credentials in the folder \\
{.path {path}} between R sessions?",
choices = c("Yes", "No")
)
choice == 1
}

cache_create <- function(path) {
Expand Down Expand Up @@ -314,19 +316,23 @@ token_match <- function(candidate, existing, package = "gargle") {
}

# we need user to OK our discovery or pick from multiple emails
emails <- extract_email(existing)
local_gargle_verbosity("info")
gargle_info(c(
choices <- c(
"Send me to the browser for a new auth process.",
extract_email(existing)
)
choice <- cli_menu(
"The {.pkg {package}} package is requesting access to your Google account.",
"Select a pre-authorised account or enter '0' to obtain a new token.",
"Press Esc/Ctrl + C to cancel."
))
choice <- utils::menu(emails)
c(
"Enter '1' to start a new auth process or select a pre-authorized account.",
"Press Esc or Ctrl + C to cancel."
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe you could omit this line now that 0 has its typical behaviour?

),
choices = choices
)

if (choice == 0) {
if (choice == 1) {
NULL
} else {
existing[[choice]]
existing[[choice - 1]]
}
}

Expand Down
87 changes: 87 additions & 0 deletions R/utils-ui.R
Original file line number Diff line number Diff line change
Expand Up @@ -250,3 +250,90 @@ compute_n_show <- function(n, n_show_nominal = 5, n_fudge = 2) {
n
}
}

# menu(), but based on readline() + cli and mockable ---------------------------
# https://github.com/r-lib/cli/issues/228
# https://github.com/rstudio/rsconnect/blob/main/R/utils-cli.R

cli_menu <- function(header,
prompt,
choices,
not_interactive = choices,
exit = integer(),
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I called this exit (instead of quit) to better match the typical vocabulary here (e.g. in the docs for utils::menu()) and to avoid any confusion with actuallly quitting R.

.envir = caller_env(),
error_call = caller_env()) {
if (!is_interactive()) {
cli::cli_abort(
c(header, not_interactive),
.envir = .envir,
call = error_call
)
}

choices <- paste0(cli::style_bold(seq_along(choices)), ": ", choices)
cli::cli_inform(
c(header, prompt, choices),
.envir = .envir
)

repeat {
selected <- cli_readline("Selection: ")
if (selected %in% c("0", seq_along(choices))) {
break
}

# guard against invalid mocked input and an infinite loop
local_input <- getOption("cli_input", character())
if (length(local_input) > 0) {
cli::cli_abort(
c(x = "Internal error: mocked input is invalid."),
.envir = .envir,
call = error_call
)
}
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The second part of "avoiding an infinite loop".

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we do that all in one place? Or extract it out into a helper?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

OK I consolidated things, just to make it a bit neater, at least.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why does it error if it’s length greater than zero? The original intent was to be able to supply multiple values that were used in turn.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Over in rsconnect, if I add this test, it causes the test suite to just hang:

test_that("cli_menu() works with multiple inputs", {
  simulate_user_input(3)

  expect_snapshot(
    cli_menu(
      "Let's talk",
      "Are you OK?",
      choices = c("Yes", "No")
    )
  )
})

Screenshot 2023-04-04 at 9 24 46 PM

I guess I haven't found an organic need to supply multiple values. But I have managed to supply an invalid one. So it seems nice for development purposes to handle that case.

I can try to think about how to preserve both.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Are you convinced that multiple mocked inputs is important?

It feels like we could just make multiple calls to simulate_user_input() (rsconnect) or local_user_input() (here in gargle) in such a test and let the functions be simpler.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I realize I can pursue the simplification that's most natural here. I'm mostly talking about what we might eventually want in cli or in some standalone file for general use.


cli::cli_inform(
"Enter a number between 1 and {length(choices)}, or enter 0 to exit."
)
}

selected <- as.integer(selected)
if (selected %in% c(0, exit)) {
if (is_testing()) {
cli::cli_abort("Exiting...", call = NULL)
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Exiting not quitting

} else {
cli::cli_alert_danger("Exiting...")
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ditto

# simulate user pressing Ctrl + C
invokeRestart("abort")
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Omitted the rogue cnd that the rsconnect version has here (presumably a think-o).

}
}

selected
}

cli_readline <- function(prompt) {
local_input <- getOption("cli_input", character())

# not convinced that we need to plan for multiple mocked inputs, but leaving
# this feature in for now
if (length(local_input) > 0) {
input <- local_input[[1]]
cli::cli_inform(paste0(prompt, input))
options(local_input = local_input[-1])
input
} else {
readline(prompt)
}
}

local_user_input <- function(x, env = caller_env()) {
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Went with a local_*()-style name

withr::local_options(
rlang_interactive = TRUE,
cli_input = as.character(x),
.local_envir = env
)
}

is_testing <- function() {
identical(Sys.getenv("TESTTHAT"), "true")
}
93 changes: 93 additions & 0 deletions tests/testthat/_snaps/utils-ui.md
Original file line number Diff line number Diff line change
Expand Up @@ -148,3 +148,96 @@
* g
* h

# cli_menu() basic usage

Code
cli_menu_with_mock(1)
Message
Found multiple thingies.
Which one do you want to use?
1: label a
2: label b
3: label c
Selection: 1
Output
[1] 1

# cli_menu() invalid selection

Code
cli_menu_with_mock("nope")
Message
Found multiple thingies.
Which one do you want to use?
1: label a
2: label b
3: label c
Selection: nope
Condition
Error in `cli_menu_with_mock()`:
x Internal error: mocked input is invalid.

# cli_menu(), request exit via 0

Code
cli_menu_with_mock(0)
Message
Found multiple thingies.
Which one do you want to use?
1: label a
2: label b
3: label c
Selection: 0
Condition
Error:
! Exiting...

# cli_menu(exit =) works

Code
cli_menu_with_mock(1)
Message
Hey we need to talk.
What do you want to do?
1: Give up
2: Some other thing
Selection: 1
Condition
Error:
! Exiting...

---

Code
cli_menu_with_mock(2)
Message
Hey we need to talk.
What do you want to do?
1: Give up
2: Some other thing
Selection: 2
Output
[1] 2

# cli_menu() inline markup and environment passing

Code
cli_menu_with_mock(1)
Message
Hey we need to "talk".
What do you want to "do"?
1: Send email to '[email protected]'
2: Install the nifty package
Selection: 1
Output
[1] 1

# cli_menu() not_interactive, many strings, chained error

Code
wrapper_fun()
Condition
Error in `wrapper_fun()`:
! Multiple things found.
i Use `thingy` to specify one of "thing 1", "thing 2", and "thing 3".

2 changes: 1 addition & 1 deletion tests/testthat/test-Gargle-class.R
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ test_that("email can be set in option", {
})

test_that("Attempt to initiate OAuth2 flow fails if non-interactive", {
rlang::local_interactive(FALSE)
local_interactive(FALSE)
expect_snapshot(gargle2.0_token(cache = FALSE), error = TRUE)
})

Expand Down
1 change: 1 addition & 0 deletions tests/testthat/test-oauth-cache.R
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@ test_that("default is to consult and set the oauth cache option", {
# cache_allowed() --------------------------------------------------------------

test_that("cache_allowed() returns false when non-interactive (or testing)", {
local_interactive(FALSE)
expect_false(cache_allowed(getwd()))
})

Expand Down
91 changes: 91 additions & 0 deletions tests/testthat/test-utils-ui.R
Original file line number Diff line number Diff line change
Expand Up @@ -58,3 +58,94 @@ test_that("bulletize() works", {
expect_snapshot(cli::cli_bullets(bulletize(letters[1:6], n_fudge = 0)))
expect_snapshot(cli::cli_bullets(bulletize(letters[1:8], n_fudge = 3)))
})

# menu(), but based on readline() + cli and mockable ---------------------------
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This basically covers the cli_menu() stuff 100%, except the lines you can only hit in interactive use.


test_that("cli_menu() basic usage", {
cli_menu_with_mock <- function(x) {
local_user_input(x)
cli_menu(
"Found multiple thingies.",
"Which one do you want to use?",
glue("label {head(letters, 3)}")
)
}

expect_snapshot(cli_menu_with_mock(1))
})

test_that("cli_menu() invalid selection", {
cli_menu_with_mock <- function(x) {
local_user_input(x)
cli_menu(
"Found multiple thingies.",
"Which one do you want to use?",
glue("label {head(letters, 3)}")
)
}

expect_snapshot(cli_menu_with_mock("nope"), error = TRUE)
})

test_that("cli_menu(), request exit via 0", {
cli_menu_with_mock <- function(x) {
local_user_input(x)
cli_menu(
"Found multiple thingies.",
"Which one do you want to use?",
glue("label {head(letters, 3)}")
)
}

expect_snapshot(error = TRUE, cli_menu_with_mock(0))
})

test_that("cli_menu(exit =) works", {
cli_menu_with_mock <- function(x) {
local_user_input(x)
cli_menu(
header = "Hey we need to talk.",
prompt = "What do you want to do?",
choices = c(
"Give up",
"Some other thing"
),
exit = 1
)
}

expect_snapshot(error = TRUE, cli_menu_with_mock(1))
expect_snapshot(cli_menu_with_mock(2))
})

test_that("cli_menu() inline markup and environment passing", {
cli_menu_with_mock <- function(x) {
local_user_input(x)
verb <- "talk"
action <- "do"
pkg_name <- "nifty"
cli_menu(
header = "Hey we need to {.str {verb}}.",
prompt = "What do you want to {.str {action}}?",
choices = c(
"Send email to {.email [email protected]}",
"Install the {.pkg {pkg_name}} package"
)
)
}
expect_snapshot(cli_menu_with_mock(1))
})

test_that("cli_menu() not_interactive, many strings, chained error", {
wrapper_fun <- function() {
local_interactive(FALSE)
things <- glue("thing {1:3}")
cli_menu(
header = "Multiple things found.",
prompt = "Which one do you want to use?",
choices = things,
not_interactive = c(i = "Use {.arg thingy} to specify one of {.str {things}}.")
)
}
expect_snapshot(wrapper_fun(), error = TRUE)
})