mirror of
https://github.com/rstudio/shiny.git
synced 2026-04-07 03:00:20 -04:00
Merge pull request #1141 from rstudio/notifications
Notification interface
This commit is contained in:
@@ -121,6 +121,7 @@ Collate:
|
||||
'middleware-shiny.R'
|
||||
'middleware.R'
|
||||
'modules.R'
|
||||
'notifications.R'
|
||||
'priorityqueue.R'
|
||||
'progress.R'
|
||||
'react.R'
|
||||
|
||||
@@ -155,6 +155,7 @@ export(reactiveValues)
|
||||
export(reactiveValuesToList)
|
||||
export(registerInputHandler)
|
||||
export(removeInputHandler)
|
||||
export(removeNotification)
|
||||
export(renderDataTable)
|
||||
export(renderImage)
|
||||
export(renderPlot)
|
||||
@@ -179,6 +180,7 @@ export(shinyAppDir)
|
||||
export(shinyAppFile)
|
||||
export(shinyServer)
|
||||
export(shinyUI)
|
||||
export(showNotification)
|
||||
export(showReactLog)
|
||||
export(sidebarLayout)
|
||||
export(sidebarPanel)
|
||||
|
||||
@@ -27,3 +27,21 @@ createWebDependency <- function(dependency) {
|
||||
|
||||
return(dependency)
|
||||
}
|
||||
|
||||
|
||||
# Given a Shiny tag object, process singletons and dependencies. Returns a list
|
||||
# with rendered HTML and dependency objects.
|
||||
processDeps <- function(tags, session) {
|
||||
ui <- takeSingletons(tags, session$singletons, desingleton=FALSE)$ui
|
||||
ui <- surroundSingletons(ui)
|
||||
dependencies <- lapply(
|
||||
resolveDependencies(findDependencies(ui)),
|
||||
createWebDependency
|
||||
)
|
||||
names(dependencies) <- NULL
|
||||
|
||||
list(
|
||||
html = doRenderTags(ui),
|
||||
deps = dependencies
|
||||
)
|
||||
}
|
||||
|
||||
96
R/notifications.R
Normal file
96
R/notifications.R
Normal file
@@ -0,0 +1,96 @@
|
||||
#' Show or remove a notification
|
||||
#'
|
||||
#' These functions show and remove notifications in a Shiny application.
|
||||
#'
|
||||
#' @param ui Content of message.
|
||||
#' @param duration Number of seconds to display the message before it
|
||||
#' disappears. Use \code{NULL} to make the message not automatically
|
||||
#' disappear.
|
||||
#' @param closeButton If \code{TRUE}, display a button which will make the
|
||||
#' notification disappear when clicked. If \code{FALSE} do not display.
|
||||
#' @param id An ID string. This can be used to change the contents of an
|
||||
#' existing message with \code{showNotification}, or to remove it with
|
||||
#' \code{removeNotification}. If not provided, one will be generated
|
||||
#' automatically. If an ID is provided and there does not currently exist a
|
||||
#' notification with that ID, a new notification will be created with that ID.
|
||||
#' @param type A string which controls the color of the notification. One of
|
||||
#' "default" (gray), "message" (blue), "warning" (yellow), or "error" (red).
|
||||
#' @param session Session object to send notification to.
|
||||
#'
|
||||
#' @return An ID for the notification.
|
||||
#'
|
||||
#' @examples
|
||||
#' if (interactive()) {
|
||||
#' # Show a message when button is clicked
|
||||
#' shinyApp(
|
||||
#' ui = fluidPage(
|
||||
#' actionButton("show", "Show")
|
||||
#' ),
|
||||
#' server = function(input, output) {
|
||||
#' observeEvent(input$show, {
|
||||
#' showNotification("Message text")
|
||||
#' })
|
||||
#' }
|
||||
#' )
|
||||
#'
|
||||
#' # App with show and remove buttons
|
||||
#' shinyApp(
|
||||
#' ui = fluidPage(
|
||||
#' actionButton("show", "Show"),
|
||||
#' actionButton("remove", "Remove")
|
||||
#' ),
|
||||
#' server = function(input, output) {
|
||||
#' # A queue of notification IDs
|
||||
#' ids <- character(0)
|
||||
#' # A counter
|
||||
#' n <- 0
|
||||
#'
|
||||
#' observeEvent(input$show, {
|
||||
#' # Save the ID for removal later
|
||||
#' id <- showNotification(paste("Message", n), duration = NULL)
|
||||
#' ids <<- c(ids, id)
|
||||
#' n <<- n + 1
|
||||
#' })
|
||||
#'
|
||||
#' observeEvent(input$remove, {
|
||||
#' if (length(ids) > 0)
|
||||
#' removeNotification(ids[1])
|
||||
#' ids <<- ids[-1]
|
||||
#' })
|
||||
#' }
|
||||
#' )
|
||||
#' }
|
||||
#' @export
|
||||
showNotification <- function(ui, duration = 5, closeButton = TRUE,
|
||||
id = NULL, type = c("default", "message", "warning", "error"),
|
||||
session = getDefaultReactiveDomain())
|
||||
{
|
||||
|
||||
if (is.null(id))
|
||||
id <- randomID()
|
||||
|
||||
res <- processDeps(ui, session)
|
||||
|
||||
session$sendNotification("show",
|
||||
list(
|
||||
html = res$html,
|
||||
deps = res$deps,
|
||||
duration = if (!is.null(duration)) duration * 1000,
|
||||
closeButton = closeButton,
|
||||
id = id,
|
||||
type = match.arg(type)
|
||||
)
|
||||
)
|
||||
|
||||
id
|
||||
}
|
||||
|
||||
#' @rdname showNotification
|
||||
#' @export
|
||||
removeNotification <- function(id = NULL, session = getDefaultReactiveDomain()) {
|
||||
if (is.null(id)) {
|
||||
stop("id is required.")
|
||||
}
|
||||
session$sendNotification("remove", id)
|
||||
id
|
||||
}
|
||||
@@ -87,7 +87,7 @@ Progress <- R6Class(
|
||||
stop("'session' is not a ShinySession object.")
|
||||
|
||||
private$session <- session
|
||||
private$id <- paste(as.character(as.raw(stats::runif(8, min=0, max=255))), collapse='')
|
||||
private$id <- randomID()
|
||||
private$min <- min
|
||||
private$max <- max
|
||||
private$value <- NULL
|
||||
|
||||
@@ -685,6 +685,11 @@ ShinySession <- R6Class(
|
||||
progress = list(type = type, message = message)
|
||||
)
|
||||
},
|
||||
sendNotification = function(type, message) {
|
||||
private$sendMessage(
|
||||
notification = list(type = type, message = message)
|
||||
)
|
||||
},
|
||||
dispatch = function(msg) {
|
||||
method <- paste('@', msg$method, sep='')
|
||||
func <- try(self[[method]], silent = TRUE)
|
||||
|
||||
@@ -276,19 +276,7 @@ renderUI <- function(expr, env=parent.frame(), quoted=FALSE, func=NULL) {
|
||||
if (is.null(result) || length(result) == 0)
|
||||
return(NULL)
|
||||
|
||||
result <- takeSingletons(result, shinysession$singletons, desingleton=FALSE)$ui
|
||||
result <- surroundSingletons(result)
|
||||
dependencies <- lapply(resolveDependencies(findDependencies(result)),
|
||||
createWebDependency)
|
||||
names(dependencies) <- NULL
|
||||
|
||||
# renderTags returns a list with head, singletons, and html
|
||||
output <- list(
|
||||
html = doRenderTags(result),
|
||||
deps = dependencies
|
||||
)
|
||||
|
||||
return(output)
|
||||
processDeps(result, shinysession)
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
10
R/utils.R
10
R/utils.R
@@ -120,6 +120,16 @@ p_randomInt <- function(...) {
|
||||
withPrivateSeed(randomInt(...))
|
||||
}
|
||||
|
||||
# Return a random hexadecimal string with `length` digits.
|
||||
randomID <- function(length = 16) {
|
||||
paste(sample(
|
||||
c("0", "1", "2", "3", "4", "5", "6", "7", "8","9",
|
||||
"a", "b", "c", "d", "e", "f"),
|
||||
length,
|
||||
replace = TRUE
|
||||
), collapse = '')
|
||||
}
|
||||
|
||||
isWholeNum <- function(x, tol = .Machine$double.eps^0.5) {
|
||||
abs(x - round(x)) < tol
|
||||
}
|
||||
|
||||
@@ -268,3 +268,57 @@ body.disconnected {
|
||||
.shiny-input-container > div > select:not(.selectized) {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
|
||||
#shiny-notification-panel {
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
right: 0;
|
||||
background-color: rgba(0,0,0,0);
|
||||
padding: 2px;
|
||||
width: 250px;
|
||||
z-index: 9999;
|
||||
}
|
||||
|
||||
.shiny-notification {
|
||||
background-color: #e8e8e8;
|
||||
color: #333;
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 3px;
|
||||
opacity: 0.85;
|
||||
padding: 10px 8px 10px 10px;
|
||||
margin: 2px;
|
||||
}
|
||||
|
||||
.shiny-notification-message {
|
||||
color: #31708f;
|
||||
background-color: #d9edf7;
|
||||
border: 1px solid #bce8f1;
|
||||
}
|
||||
|
||||
.shiny-notification-warning {
|
||||
color: #8a6d3b;
|
||||
background-color: #fcf8e3;
|
||||
border: 1px solid #faebcc;
|
||||
}
|
||||
|
||||
.shiny-notification-error {
|
||||
color: #a94442;
|
||||
background-color: #f2dede;
|
||||
border: 1px solid #ebccd1;
|
||||
}
|
||||
|
||||
.shiny-notification-close {
|
||||
float: right;
|
||||
font-weight: bold;
|
||||
font-size: 18px;
|
||||
bottom: 9px;
|
||||
position: relative;
|
||||
padding-left: 4px;
|
||||
color: #444;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.shiny-notification-close:hover {
|
||||
color: #000;
|
||||
}
|
||||
|
||||
83
man/showNotification.Rd
Normal file
83
man/showNotification.Rd
Normal file
@@ -0,0 +1,83 @@
|
||||
% Generated by roxygen2: do not edit by hand
|
||||
% Please edit documentation in R/notifications.R
|
||||
\name{showNotification}
|
||||
\alias{removeNotification}
|
||||
\alias{showNotification}
|
||||
\title{Show or remove a notification}
|
||||
\usage{
|
||||
showNotification(ui, duration = 5, closeButton = TRUE, id = NULL,
|
||||
type = c("default", "message", "warning", "error"),
|
||||
session = getDefaultReactiveDomain())
|
||||
|
||||
removeNotification(id = NULL, session = getDefaultReactiveDomain())
|
||||
}
|
||||
\arguments{
|
||||
\item{ui}{Content of message.}
|
||||
|
||||
\item{duration}{Number of seconds to display the message before it
|
||||
disappears. Use \code{NULL} to make the message not automatically
|
||||
disappear.}
|
||||
|
||||
\item{closeButton}{If \code{TRUE}, display a button which will make the
|
||||
notification disappear when clicked. If \code{FALSE} do not display.}
|
||||
|
||||
\item{id}{An ID string. This can be used to change the contents of an
|
||||
existing message with \code{showNotification}, or to remove it with
|
||||
\code{removeNotification}. If not provided, one will be generated
|
||||
automatically. If an ID is provided and there does not currently exist a
|
||||
notification with that ID, a new notification will be created with that ID.}
|
||||
|
||||
\item{type}{A string which controls the color of the notification. One of
|
||||
"default" (gray), "message" (blue), "warning" (yellow), or "error" (red).}
|
||||
|
||||
\item{session}{Session object to send notification to.}
|
||||
}
|
||||
\value{
|
||||
An ID for the notification.
|
||||
}
|
||||
\description{
|
||||
These functions show and remove notifications in a Shiny application.
|
||||
}
|
||||
\examples{
|
||||
if (interactive()) {
|
||||
# Show a message when button is clicked
|
||||
shinyApp(
|
||||
ui = fluidPage(
|
||||
actionButton("show", "Show")
|
||||
),
|
||||
server = function(input, output) {
|
||||
observeEvent(input$show, {
|
||||
showNotification("Message text")
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
# App with show and remove buttons
|
||||
shinyApp(
|
||||
ui = fluidPage(
|
||||
actionButton("show", "Show"),
|
||||
actionButton("remove", "Remove")
|
||||
),
|
||||
server = function(input, output) {
|
||||
# A queue of notification IDs
|
||||
ids <- character(0)
|
||||
# A counter
|
||||
n <- 0
|
||||
|
||||
observeEvent(input$show, {
|
||||
# Save the ID for removal later
|
||||
id <- showNotification(paste("Message", n), duration = NULL)
|
||||
ids <<- c(ids, id)
|
||||
n <<- n + 1
|
||||
})
|
||||
|
||||
observeEvent(input$remove, {
|
||||
if (length(ids) > 0)
|
||||
removeNotification(ids[1])
|
||||
ids <<- ids[-1]
|
||||
})
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
150
srcjs/notifications.js
Normal file
150
srcjs/notifications.js
Normal file
@@ -0,0 +1,150 @@
|
||||
exports.notifications = (function() {
|
||||
|
||||
// Milliseconds to fade in or out
|
||||
const fadeDuration = 250;
|
||||
|
||||
function show({ html=null, deps=[], duration=5000, id=null,
|
||||
closeButton=true, type=null } = {})
|
||||
{
|
||||
if (!id)
|
||||
id = randomId();
|
||||
|
||||
// Create panel if necessary
|
||||
_createPanel();
|
||||
|
||||
// Get existing DOM element for this ID, or create if needed.
|
||||
let $notification = _get(id);
|
||||
if ($notification.length === 0)
|
||||
$notification = _create(id);
|
||||
|
||||
// Render html and dependencies
|
||||
const $content = $notification.find('.shiny-notification-content');
|
||||
exports.renderContent($content, { html, deps });
|
||||
|
||||
// Remove any existing classes of the form 'shiny-notification-xxxx'.
|
||||
// The xxxx would be strings like 'warning'.
|
||||
const classes = $notification.attr('class')
|
||||
.split(/\s+/)
|
||||
.filter(cls => cls.match(/^shiny-notification-/))
|
||||
.join(' ');
|
||||
$notification.removeClass(classes);
|
||||
|
||||
// Add class. 'default' means no additional CSS class.
|
||||
if (type && type !== 'default')
|
||||
$notification.addClass('shiny-notification-' + type);
|
||||
|
||||
|
||||
// Make sure that the presence/absence of close button matches with value
|
||||
// of `closeButton`.
|
||||
const $close = $notification.find('.shiny-notification-close');
|
||||
if (closeButton && $close.length === 0) {
|
||||
$notification.append('<div class="shiny-notification-close">×</div>');
|
||||
} else if (!closeButton && $close.length !== 0) {
|
||||
$close.remove();
|
||||
}
|
||||
|
||||
// If duration was provided, schedule removal. If not, clear existing
|
||||
// removal callback (this happens if a message was first added with
|
||||
// a duration, and then updated with no duration).
|
||||
if (duration)
|
||||
_addRemovalCallback(id, duration);
|
||||
else
|
||||
_clearRemovalCallback(id);
|
||||
|
||||
return id;
|
||||
}
|
||||
|
||||
function remove(id) {
|
||||
_get(id).fadeOut(fadeDuration, function() {
|
||||
|
||||
exports.unbindAll(this);
|
||||
this.remove();
|
||||
|
||||
// If no more notifications, remove the panel from the DOM.
|
||||
if (_ids().length === 0) {
|
||||
_getPanel().remove();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Returns an individual notification DOM object (wrapped in jQuery).
|
||||
function _get(id) {
|
||||
return _getPanel().find('#shiny-notification-' + $escape(id));
|
||||
}
|
||||
|
||||
// Return array of all notification IDs
|
||||
function _ids() {
|
||||
return _getPanel()
|
||||
.find('.shiny-notification')
|
||||
.map(function() { return this.id.replace(/shiny-notification-/, ''); })
|
||||
.get();
|
||||
}
|
||||
|
||||
// Returns the notification panel DOM object (wrapped in jQuery).
|
||||
function _getPanel() {
|
||||
return $('#shiny-notification-panel');
|
||||
}
|
||||
|
||||
// Create notifications panel and return the jQuery object. If the DOM
|
||||
// element already exists, just return it.
|
||||
function _createPanel() {
|
||||
let $panel = _getPanel();
|
||||
|
||||
if ($panel.length > 0)
|
||||
return $panel;
|
||||
|
||||
$('body').append('<div id="shiny-notification-panel">');
|
||||
|
||||
return $panel;
|
||||
}
|
||||
|
||||
// Create a notification DOM element and return the jQuery object. If the
|
||||
// DOM element already exists for the ID, just return it without creating.
|
||||
function _create(id) {
|
||||
let $notification = _get(id);
|
||||
|
||||
if ($notification.length === 0) {
|
||||
$notification = $(
|
||||
`<div id="shiny-notification-${id}" class="shiny-notification">` +
|
||||
'<div class="shiny-notification-close">×</div>' +
|
||||
'<div class="shiny-notification-content"></div>' +
|
||||
'</div>'
|
||||
);
|
||||
|
||||
$notification.find('.shiny-notification-close').on('click', e => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
remove(id);
|
||||
});
|
||||
|
||||
_getPanel().append($notification);
|
||||
}
|
||||
|
||||
return $notification;
|
||||
}
|
||||
|
||||
// Add a callback to remove a notification after a delay in ms.
|
||||
function _addRemovalCallback(id, delay) {
|
||||
// If there's an existing removalCallback, clear it before adding the new
|
||||
// one.
|
||||
_clearRemovalCallback(id);
|
||||
|
||||
// Attach new removal callback
|
||||
const removalCallback = setTimeout(function() { remove(id); }, delay);
|
||||
_get(id).data('removalCallback', removalCallback);
|
||||
}
|
||||
|
||||
// Clear a removal callback from a notification, if present.
|
||||
function _clearRemovalCallback(id) {
|
||||
const $notification = _get(id);
|
||||
const oldRemovalCallback = $notification.data('removalCallback');
|
||||
if (oldRemovalCallback) {
|
||||
clearTimeout(oldRemovalCallback);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
show,
|
||||
remove
|
||||
};
|
||||
})();
|
||||
@@ -8,22 +8,7 @@ $.extend(htmlOutputBinding, {
|
||||
this.renderError(el, err);
|
||||
},
|
||||
renderValue: function(el, data) {
|
||||
exports.unbindAll(el);
|
||||
|
||||
var html;
|
||||
var dependencies = [];
|
||||
if (data === null) {
|
||||
html = '';
|
||||
} else if (typeof(data) === 'string') {
|
||||
html = data;
|
||||
} else if (typeof(data) === 'object') {
|
||||
html = data.html;
|
||||
dependencies = data.deps;
|
||||
}
|
||||
|
||||
exports.renderHtml(html, el, dependencies);
|
||||
exports.initializeInputs(el);
|
||||
exports.bindAll(el);
|
||||
exports.renderContent(el, data);
|
||||
}
|
||||
});
|
||||
outputBindings.register(htmlOutputBinding, 'shiny.htmlOutput');
|
||||
@@ -36,6 +21,28 @@ var renderDependencies = exports.renderDependencies = function(dependencies) {
|
||||
}
|
||||
};
|
||||
|
||||
// Render HTML in a DOM element, add dependencies, and bind Shiny
|
||||
// inputs/outputs. `content` can be null, a string, or an object with
|
||||
// properties 'html' and 'deps'.
|
||||
exports.renderContent = function(el, content) {
|
||||
exports.unbindAll(el);
|
||||
|
||||
var html;
|
||||
var dependencies = [];
|
||||
if (content === null) {
|
||||
html = '';
|
||||
} else if (typeof(content) === 'string') {
|
||||
html = content;
|
||||
} else if (typeof(content) === 'object') {
|
||||
html = content.html;
|
||||
dependencies = content.deps || [];
|
||||
}
|
||||
|
||||
exports.renderHtml(html, el, dependencies);
|
||||
exports.initializeInputs(el);
|
||||
exports.bindAll(el);
|
||||
};
|
||||
|
||||
// Render HTML in a DOM element, inserting singletons into head as needed
|
||||
exports.renderHtml = function(html, el, dependencies) {
|
||||
renderDependencies(dependencies);
|
||||
|
||||
@@ -468,6 +468,15 @@ var ShinyApp = function() {
|
||||
}
|
||||
});
|
||||
|
||||
addMessageHandler('notification', function(message) {
|
||||
if (message.type === 'show')
|
||||
exports.notifications.show(message.message);
|
||||
else if (message.type === 'remove')
|
||||
exports.notifications.remove(message.message);
|
||||
else
|
||||
throw('Unkown notification type: ' + message.type);
|
||||
});
|
||||
|
||||
addMessageHandler('response', function(message) {
|
||||
var requestId = message.tag;
|
||||
var request = this.$activeRequests[requestId];
|
||||
|
||||
@@ -7,6 +7,10 @@ function escapeHTML(str) {
|
||||
.replace(/\//g,"/");
|
||||
}
|
||||
|
||||
function randomId() {
|
||||
return Math.floor(0x100000000 + (Math.random() * 0xF00000000)).toString(16);
|
||||
}
|
||||
|
||||
function strToBool(str) {
|
||||
if (!str || !str.toLowerCase)
|
||||
return undefined;
|
||||
|
||||
@@ -33,6 +33,7 @@ module.exports = function(grunt) {
|
||||
js_srcdir + 'browser.js',
|
||||
js_srcdir + 'input_rate.js',
|
||||
js_srcdir + 'shinyapp.js',
|
||||
js_srcdir + 'notifications.js',
|
||||
js_srcdir + 'file_processor.js',
|
||||
js_srcdir + 'binding_registry.js',
|
||||
js_srcdir + 'output_binding.js',
|
||||
|
||||
Reference in New Issue
Block a user