Merge pull request #1141 from rstudio/notifications

Notification interface
This commit is contained in:
Winston Chang
2016-03-24 17:09:31 -05:00
15 changed files with 458 additions and 30 deletions

View File

@@ -121,6 +121,7 @@ Collate:
'middleware-shiny.R'
'middleware.R'
'modules.R'
'notifications.R'
'priorityqueue.R'
'progress.R'
'react.R'

View File

@@ -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)

View File

@@ -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
View 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
}

View File

@@ -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

View File

@@ -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)

View File

@@ -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)
})
}

View File

@@ -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
}

View File

@@ -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
View 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
View 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">&times;</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">&times;</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
};
})();

View File

@@ -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);

View File

@@ -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];

View File

@@ -7,6 +7,10 @@ function escapeHTML(str) {
.replace(/\//g,"&#x2F;");
}
function randomId() {
return Math.floor(0x100000000 + (Math.random() * 0xF00000000)).toString(16);
}
function strToBool(str) {
if (!str || !str.toLowerCase)
return undefined;

View File

@@ -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',