Skip to content

Commit f331ffe

Browse files
authored
898 save app state version 3 (#1011)
Closes #898 Closes #941 Incorporating bookmarking to `teal` applications.
1 parent ad3ff56 commit f331ffe

29 files changed

+990
-198
lines changed

DESCRIPTION

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,12 +93,14 @@ Collate:
9393
'modules.R'
9494
'init.R'
9595
'landing_popup_module.R'
96+
'module_bookmark_manager.R'
9697
'module_filter_manager.R'
9798
'module_nested_tabs.R'
9899
'module_snapshot_manager.R'
99100
'module_tabs_with_filters.R'
100101
'module_teal.R'
101102
'module_teal_with_splash.R'
103+
'module_wunder_bar.R'
102104
'reporter_previewer_module.R'
103105
'show_rcode_modal.R'
104106
'tdata.R'

NEWS.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
11
# teal 0.15.2.9018
22

3+
### Miscellaneous
4+
* Filter mapping display is no longer coupled to the snapshot manager.
5+
36
# teal 0.15.2
47

58
### Bug fixes

R/dummy_functions.R

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,12 +15,16 @@
1515
#' @export
1616
example_module <- function(label = "example teal module", datanames = "all") {
1717
checkmate::assert_string(label)
18-
module(
18+
ans <- module(
1919
label,
2020
server = function(id, data) {
2121
checkmate::assert_class(data(), "teal_data")
2222
moduleServer(id, function(input, output, session) {
23-
updateSelectInput(session, "dataname", choices = isolate(teal.data::datanames(data())))
23+
updateSelectInput(
24+
inputId = "dataname",
25+
choices = isolate(teal.data::datanames(data())),
26+
selected = restoreInput(session$ns("dataname"), NULL)
27+
)
2428
output$text <- renderPrint({
2529
req(input$dataname)
2630
data()[[input$dataname]]
@@ -44,4 +48,6 @@ example_module <- function(label = "example teal module", datanames = "all") {
4448
},
4549
datanames = datanames
4650
)
51+
attr(ans, "teal_bookmarkable") <- TRUE
52+
ans
4753
}

R/init.R

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@
3535
#' string specifying the `shiny` module id in cases it is used as a `shiny` module
3636
#' rather than a standalone `shiny` app. This is a legacy feature.
3737
#'
38-
#' @return Named list with server and UI functions.
38+
#' @return Named list containing server and UI functions.
3939
#'
4040
#' @export
4141
#'
@@ -164,8 +164,8 @@ init <- function(data,
164164
stop("Only one `landing_popup_module` can be used.")
165165
}
166166

167-
## `filter` - app_id attribute
168-
attr(filter, "app_id") <- create_app_id(data, modules)
167+
## `filter` - set app_id attribute unless present (when restoring bookmark)
168+
if (is.null(attr(filter, "app_id", exact = TRUE))) attr(filter, "app_id") <- create_app_id(data, modules)
169169

170170
## `filter` - convert teal.slice::teal_slices to teal::teal_slices
171171
filter <- as.teal_slices(as.list(filter))
@@ -221,8 +221,9 @@ init <- function(data,
221221
# Note regarding case `id = character(0)`:
222222
# rather than creating a submodule of this module, we directly modify
223223
# the UI and server with `id = character(0)` and calling the server function directly
224+
# Note: UI must be a function to support bookmarking.
224225
res <- list(
225-
ui = ui_teal_with_splash(id = id, data = data, title = title, header = header, footer = footer),
226+
ui = function(request) ui_teal_with_splash(id = id, data = data, title = title, header = header, footer = footer),
226227
server = function(input, output, session) {
227228
if (!is.null(landing_module)) {
228229
do.call(landing_module$server, c(list(id = "landing_module_shiny_id"), landing_module$server_args))

R/module_bookmark_manager.R

Lines changed: 313 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,313 @@
1+
#' App state management.
2+
#'
3+
#' @description
4+
#' `r lifecycle::badge("experimental")`
5+
#'
6+
#' Capture and restore the global (app) input state.
7+
#'
8+
#' @details
9+
#' This module introduces bookmarks into `teal` apps: the `shiny` bookmarking mechanism becomes enabled
10+
#' and server-side bookmarks can be created.
11+
#'
12+
#' The bookmark manager presents a button with the bookmark icon and is placed in the [`wunder_bar`].
13+
#' When clicked, the button creates a bookmark and opens a modal which displays the bookmark URL.
14+
#'
15+
#' `teal` does not guarantee that all modules (`teal_module` objects) are bookmarkable.
16+
#' Those that are, have a `teal_bookmarkable` attribute set to `TRUE`. If any modules are not bookmarkable,
17+
#' the bookmark manager modal displays a warning and the bookmark button displays a flag.
18+
#' In order to communicate that a external module is bookmarkable, the module developer
19+
#' should set the `teal_bookmarkable` attribute to `TRUE`.
20+
#'
21+
#' @section Server logic:
22+
#' A bookmark is a URL that contains the app address with a `/?_state_id_=<bookmark_dir>` suffix.
23+
#' `<bookmark_dir>` is a directory created on the server, where the state of the application is saved.
24+
#' Accessing the bookmark URL opens a new session of the app that starts in the previously saved state.
25+
#'
26+
#' @section Note:
27+
#' To enable bookmarking use either:
28+
#' - `shiny` app by using `shinyApp(..., enableBookmarking = "server")` (not supported in `shinytest2`)
29+
#' - set `options(shiny.bookmarkStore = "server")` before running the app
30+
#'
31+
#'
32+
#' @inheritParams module_wunder_bar
33+
#'
34+
#' @return Invisible `NULL`.
35+
#'
36+
#' @aliases bookmark bookmark_manager bookmark_manager_module
37+
#'
38+
#' @name module_bookmark_manager
39+
#' @keywords internal
40+
#'
41+
bookmark_manager_ui <- function(id) {
42+
ns <- NS(id)
43+
uiOutput(ns("bookmark_button"), inline = TRUE)
44+
}
45+
46+
#' @rdname module_bookmark_manager
47+
#' @keywords internal
48+
#'
49+
bookmark_manager_srv <- function(id, modules) {
50+
checkmate::assert_character(id)
51+
checkmate::assert_class(modules, "teal_modules")
52+
moduleServer(id, function(input, output, session) {
53+
logger::log_trace("bookmark_manager_srv initializing")
54+
ns <- session$ns
55+
bookmark_option <- getShinyOption("bookmarkStore")
56+
if (is.null(bookmark_option) && identical(getOption("shiny.bookmarkStore"), "server")) {
57+
bookmark_option <- getOption("shiny.bookmarkStore")
58+
# option alone doesn't activate bookmarking - we need to set shinyOptions
59+
shinyOptions(bookmarkStore = bookmark_option)
60+
}
61+
62+
is_unbookmarkable <- unlist(rapply2(
63+
modules_bookmarkable(modules),
64+
Negate(isTRUE)
65+
))
66+
67+
# Render bookmark warnings count
68+
output$bookmark_button <- renderUI({
69+
if (!all(is_unbookmarkable) && identical(bookmark_option, "server")) {
70+
tags$button(
71+
id = ns("do_bookmark"),
72+
class = "btn action-button wunder_bar_button bookmark_manager_button",
73+
title = "Add bookmark",
74+
tags$span(
75+
suppressMessages(icon("solid fa-bookmark")),
76+
if (any(is_unbookmarkable)) {
77+
tags$span(
78+
sum(is_unbookmarkable),
79+
class = "badge-warning badge-count text-white bg-danger"
80+
)
81+
}
82+
)
83+
)
84+
}
85+
})
86+
87+
# Set up bookmarking callbacks ----
88+
# Register bookmark exclusions: do_bookmark button to avoid re-bookmarking
89+
setBookmarkExclude(c("do_bookmark"))
90+
# This bookmark can only be used on the app session.
91+
app_session <- .subset2(shiny::getDefaultReactiveDomain(), "parent")
92+
app_session$onBookmarked(function(url) {
93+
logger::log_trace("bookmark_manager_srv@onBookmarked: bookmark button clicked, registering bookmark")
94+
modal_content <- if (bookmark_option != "server") {
95+
msg <- sprintf(
96+
"Bookmarking has been set to \"%s\".\n%s\n%s",
97+
bookmark_option,
98+
"Only server-side bookmarking is supported.",
99+
"Please contact your app developer."
100+
)
101+
tags$div(
102+
tags$p(msg, class = "text-warning")
103+
)
104+
} else {
105+
tags$div(
106+
tags$span(
107+
tags$pre(url)
108+
),
109+
if (any(is_unbookmarkable)) {
110+
bkmb_summary <- rapply2(
111+
modules_bookmarkable(modules),
112+
function(x) {
113+
if (isTRUE(x)) {
114+
"\u2705" # check mark
115+
} else if (isFALSE(x)) {
116+
"\u274C" # cross mark
117+
} else {
118+
"\u2753" # question mark
119+
}
120+
}
121+
)
122+
tags$div(
123+
tags$p(
124+
icon("fas fa-exclamation-triangle"),
125+
"Some modules will not be restored when using this bookmark.",
126+
tags$br(),
127+
"Check the list below to see which modules are not bookmarkable.",
128+
class = "text-warning"
129+
),
130+
tags$pre(yaml::as.yaml(bkmb_summary))
131+
)
132+
}
133+
)
134+
}
135+
136+
showModal(
137+
modalDialog(
138+
id = ns("bookmark_modal"),
139+
title = "Bookmarked teal app url",
140+
modal_content,
141+
easyClose = TRUE
142+
)
143+
)
144+
})
145+
146+
# manually trigger bookmarking because of the problems reported on windows with bookmarkButton in teal
147+
observeEvent(input$do_bookmark, {
148+
logger::log_trace("bookmark_manager_srv@1 do_bookmark module clicked.")
149+
session$doBookmark()
150+
})
151+
152+
invisible(NULL)
153+
})
154+
}
155+
156+
# utilities ----
157+
158+
#' Restore value from bookmark.
159+
#'
160+
#' Get value from bookmark or return default.
161+
#'
162+
#' Bookmarks can store not only inputs but also arbitrary values.
163+
#' These values are stored by `onBookmark` callbacks and restored by `onBookmarked` callbacks,
164+
#' and they are placed in the `values` environment in the `session$restoreContext` field.
165+
#' Using `teal_data_module` makes it impossible to run the callbacks
166+
#' because the app becomes ready before modules execute and callbacks are registered.
167+
#' In those cases the stored values can still be recovered from the `session` object directly.
168+
#'
169+
#' Note that variable names in the `values` environment are prefixed with module name space names,
170+
#' therefore, when using this function in modules, `value` must be run through the name space function.
171+
#'
172+
#' @param value (`character(1)`) name of value to restore
173+
#' @param default fallback value
174+
#'
175+
#' @return
176+
#' In an application restored from a server-side bookmark,
177+
#' the variable specified by `value` from the `values` environment.
178+
#' Otherwise `default`.
179+
#'
180+
#' @keywords internal
181+
#'
182+
restoreValue <- function(value, default) { # nolint: object_name.
183+
checkmate::assert_character("value")
184+
session_default <- shiny::getDefaultReactiveDomain()
185+
session_parent <- .subset2(session_default, "parent")
186+
session <- if (is.null(session_parent)) session_default else session_parent
187+
188+
if (isTRUE(session$restoreContext$active) && exists(value, session$restoreContext$values, inherits = FALSE)) {
189+
session$restoreContext$values[[value]]
190+
} else {
191+
default
192+
}
193+
}
194+
195+
#' Compare bookmarks.
196+
#'
197+
#' Test if two bookmarks store identical state.
198+
#'
199+
#' `input` environments are compared one variable at a time and if not identical,
200+
#' values in both bookmarks are reported. States of `datatable`s are stripped
201+
#' of the `time` element before comparing because the time stamp is always different.
202+
#' The contents themselves are not printed as they are large and the contents are not informative.
203+
#' Elements present in one bookmark and absent in the other are also reported.
204+
#' Differences are printed as messages.
205+
#'
206+
#' `values` environments are compared with `all.equal`.
207+
#'
208+
#' @section How to use:
209+
#' Open an application, change relevant inputs (typically, all of them), and create a bookmark.
210+
#' Then open that bookmark and immediately create a bookmark of that.
211+
#' If restoring bookmarks occurred properly, the two bookmarks should store the same state.
212+
#'
213+
#'
214+
#' @param book1,book2 bookmark directories stored in `shiny_bookmarks/`;
215+
#' default to the two most recently modified directories
216+
#'
217+
#' @return
218+
#' Invisible `NULL` if bookmarks are identical or if there are no bookmarks to test.
219+
#' `FALSE` if inconsistencies are detected.
220+
#'
221+
#' @keywords internal
222+
#'
223+
bookmarks_identical <- function(book1, book2) {
224+
if (!dir.exists("shiny_bookmarks")) {
225+
message("no bookmark directory")
226+
return(invisible(NULL))
227+
}
228+
229+
ans <- TRUE
230+
231+
if (missing(book1) && missing(book2)) {
232+
dirs <- list.dirs("shiny_bookmarks", recursive = FALSE)
233+
bookmarks_sorted <- basename(rev(dirs[order(file.mtime(dirs))]))
234+
if (length(bookmarks_sorted) < 2L) {
235+
message("no bookmarks to compare")
236+
return(invisible(NULL))
237+
}
238+
book1 <- bookmarks_sorted[2L]
239+
book2 <- bookmarks_sorted[1L]
240+
} else {
241+
if (!dir.exists(file.path("shiny_bookmarks", book1))) stop(book1, " not found")
242+
if (!dir.exists(file.path("shiny_bookmarks", book2))) stop(book2, " not found")
243+
}
244+
245+
book1_input <- readRDS(file.path("shiny_bookmarks", book1, "input.rds"))
246+
book2_input <- readRDS(file.path("shiny_bookmarks", book2, "input.rds"))
247+
248+
elements_common <- intersect(names(book1_input), names(book2_input))
249+
dt_states <- grepl("_state$", elements_common)
250+
if (any(dt_states)) {
251+
for (el in elements_common[dt_states]) {
252+
book1_input[[el]][["time"]] <- NULL
253+
book2_input[[el]][["time"]] <- NULL
254+
}
255+
}
256+
257+
identicals <- mapply(identical, book1_input[elements_common], book2_input[elements_common])
258+
non_identicals <- names(identicals[!identicals])
259+
compares <- sprintf("$ %s:\t%s --- %s", non_identicals, book1_input[non_identicals], book2_input[non_identicals])
260+
if (length(compares) != 0L) {
261+
message("common elements not identical: \n", paste(compares, collapse = "\n"))
262+
ans <- FALSE
263+
}
264+
265+
elements_boook1 <- setdiff(names(book1_input), names(book2_input))
266+
if (length(elements_boook1) != 0L) {
267+
dt_states <- grepl("_state$", elements_boook1)
268+
if (any(dt_states)) {
269+
for (el in elements_boook1[dt_states]) {
270+
if (is.list(book1_input[[el]])) book1_input[[el]] <- "--- data table state ---"
271+
}
272+
}
273+
excess1 <- sprintf("$ %s:\t%s", elements_boook1, book1_input[elements_boook1])
274+
message("elements only in book1: \n", paste(excess1, collapse = "\n"))
275+
ans <- FALSE
276+
}
277+
278+
elements_boook2 <- setdiff(names(book2_input), names(book1_input))
279+
if (length(elements_boook2) != 0L) {
280+
dt_states <- grepl("_state$", elements_boook1)
281+
if (any(dt_states)) {
282+
for (el in elements_boook1[dt_states]) {
283+
if (is.list(book2_input[[el]])) book2_input[[el]] <- "--- data table state ---"
284+
}
285+
}
286+
excess2 <- sprintf("$ %s:\t%s", elements_boook2, book2_input[elements_boook2])
287+
message("elements only in book2: \n", paste(excess2, collapse = "\n"))
288+
ans <- FALSE
289+
}
290+
291+
book1_values <- readRDS(file.path("shiny_bookmarks", book1, "values.rds"))
292+
book2_values <- readRDS(file.path("shiny_bookmarks", book2, "values.rds"))
293+
294+
if (!isTRUE(all.equal(book1_values, book2_values))) {
295+
message("different values detected")
296+
message("choices for numeric filters MAY be different, see RangeFilterState$set_choices")
297+
ans <- FALSE
298+
}
299+
300+
if (ans) message("perfect!")
301+
invisible(NULL)
302+
}
303+
304+
305+
# Replacement for [base::rapply] which doesn't handle NULL values - skips the evaluation
306+
# of the function and returns NULL for given element.
307+
rapply2 <- function(x, f) {
308+
if (inherits(x, "list")) {
309+
lapply(x, rapply2, f = f)
310+
} else {
311+
f(x)
312+
}
313+
}

0 commit comments

Comments
 (0)