NotificationBox.js (12582B)
1 /* This Source Code Form is subject to the terms of the Mozilla Public 2 * License, v. 2.0. If a copy of the MPL was not distributed with this file, 3 * You can obtain one at http://mozilla.org/MPL/2.0/. */ 4 5 "use strict"; 6 7 const { 8 Component, 9 createFactory, 10 } = require("resource://devtools/client/shared/vendor/react.mjs"); 11 const PropTypes = require("resource://devtools/client/shared/vendor/react-prop-types.mjs"); 12 const dom = require("resource://devtools/client/shared/vendor/react-dom-factories.js"); 13 const { LocalizationHelper } = require("resource://devtools/shared/l10n.js"); 14 15 const l10n = new LocalizationHelper( 16 "devtools/client/locales/components.properties" 17 ); 18 const { div, span, button } = dom; 19 loader.lazyGetter(this, "MDNLink", function () { 20 return createFactory( 21 require("resource://devtools/client/shared/components/MdnLink.js") 22 ); 23 }); 24 25 // Priority Levels 26 const PriorityLevels = { 27 PRIORITY_INFO_LOW: 1, 28 PRIORITY_INFO_MEDIUM: 2, 29 PRIORITY_INFO_HIGH: 3, 30 // Type NEW should be used to highlight new features, and should be more 31 // eye-catchy than INFO level notifications. 32 PRIORITY_NEW: 4, 33 PRIORITY_WARNING_LOW: 5, 34 PRIORITY_WARNING_MEDIUM: 6, 35 PRIORITY_WARNING_HIGH: 7, 36 PRIORITY_CRITICAL_LOW: 8, 37 PRIORITY_CRITICAL_MEDIUM: 9, 38 PRIORITY_CRITICAL_HIGH: 10, 39 PRIORITY_CRITICAL_BLOCK: 11, 40 }; 41 42 /** 43 * This component represents Notification Box - HTML alternative for 44 * <xul:notificationbox> binding. 45 * 46 * See also MDN for more info about <xul:notificationbox>: 47 * https://developer.mozilla.org/en-US/docs/Mozilla/Tech/XUL/notificationbox 48 * 49 * This component can maintain its own state (list of notifications) 50 * as well as consume list of notifications provided as a prop 51 * (coming e.g. from Redux store). 52 */ 53 class NotificationBox extends Component { 54 static get propTypes() { 55 return { 56 // Optional box ID (used for mounted node ID attribute) 57 id: PropTypes.string, 58 /** 59 * List of notifications appended into the box. Each item of the map is an object 60 * of the following shape: 61 * - {String} label: Label to appear on the notification. 62 * - {String} value: Value used to identify the notification. Should be the same 63 * as the map key used for this notification. 64 * - {String} image: URL of image to appear on the notification. If "" then an 65 * appropriate icon for the priority level is used. 66 * - {Number} priority: Notification priority; see Priority Levels. 67 * - {Function} eventCallback: A function to call to notify you of interesting 68 things that happen with the notification box. 69 - {String} type: One of "info", "warning", or "critical" used to determine 70 what styling and icon are used for the notification. 71 * - {Array<Object>} buttons: Array of button descriptions to appear on the 72 * notification. Should be of the following shape: 73 * - {Function} callback: This function is passed 3 arguments: 74 1) the NotificationBox component 75 the button is associated with. 76 2) the button description as passed 77 to appendNotification. 78 3) the element which was the target 79 of the button press event. 80 If the return value from this function 81 is not true, then the notification is 82 closed. The notification is also not 83 closed if an error is thrown. 84 - {String} label: The label to appear on the button. 85 - {String} accesskey: The accesskey attribute set on the 86 <button> element. 87 - {String} mdnUrl: URL to MDN docs. Optional but if set 88 turns button into a MDNLink and supersedes 89 all other properties. Uses Label as the title 90 for the link. 91 */ 92 notifications: PropTypes.instanceOf(Map), 93 // Message that should be shown when hovering over the close button 94 closeButtonTooltip: PropTypes.string, 95 // Wraps text when passed from console window as wrapping: true 96 wrapping: PropTypes.bool, 97 // Display a top border (default to false) 98 displayBorderTop: PropTypes.bool, 99 // Display a bottom border (default to true) 100 displayBorderBottom: PropTypes.bool, 101 // Display a close button (default to true) 102 displayCloseButton: PropTypes.bool, 103 }; 104 } 105 106 static get defaultProps() { 107 return { 108 closeButtonTooltip: l10n.getStr("notificationBox.closeTooltip"), 109 displayBorderTop: false, 110 displayBorderBottom: true, 111 displayCloseButton: true, 112 }; 113 } 114 115 constructor(props) { 116 super(props); 117 118 this.state = { 119 notifications: new Map(), 120 }; 121 122 this.appendNotification = this.appendNotification.bind(this); 123 this.removeNotification = this.removeNotification.bind(this); 124 this.getNotificationWithValue = this.getNotificationWithValue.bind(this); 125 this.getCurrentNotification = this.getCurrentNotification.bind(this); 126 this.close = this.close.bind(this); 127 this.renderButton = this.renderButton.bind(this); 128 this.renderNotification = this.renderNotification.bind(this); 129 } 130 131 /** 132 * Create a new notification and display it. If another notification is 133 * already present with a higher priority, the new notification will be 134 * added behind it. See `propTypes` for arguments description. 135 */ 136 appendNotification( 137 label, 138 value, 139 image, 140 priority, 141 buttons = [], 142 eventCallback 143 ) { 144 const newState = appendNotification(this.state, { 145 label, 146 value, 147 image, 148 priority, 149 buttons, 150 eventCallback, 151 }); 152 153 this.setState(newState); 154 } 155 156 /** 157 * Remove specific notification from the list. 158 */ 159 removeNotification(notification) { 160 if (notification) { 161 this.close(this.state.notifications.get(notification.value)); 162 } 163 } 164 165 /** 166 * Returns an object that represents a notification. It can be 167 * used to close it. 168 */ 169 getNotificationWithValue(value) { 170 const notification = this.state.notifications.get(value); 171 if (!notification) { 172 return null; 173 } 174 175 // Return an object that can be used to remove the notification 176 // later (using `removeNotification` method) or directly close it. 177 return Object.assign({}, notification, { 178 close: () => { 179 this.close(notification); 180 }, 181 }); 182 } 183 184 getCurrentNotification() { 185 return getHighestPriorityNotification(this.state.notifications); 186 } 187 188 /** 189 * Close specified notification. 190 */ 191 close(notification) { 192 if (!notification) { 193 return; 194 } 195 196 if (notification.eventCallback) { 197 notification.eventCallback("removed"); 198 } 199 200 if (!this.state.notifications.get(notification.value)) { 201 return; 202 } 203 204 const newNotifications = new Map(this.state.notifications); 205 newNotifications.delete(notification.value); 206 this.setState({ 207 notifications: newNotifications, 208 }); 209 } 210 211 /** 212 * Render a button. A notification can have a set of custom buttons. 213 * These are used to execute custom callback. Will render a MDNLink 214 * if mdnUrl property is set. 215 */ 216 renderButton(props, notification) { 217 if (props.mdnUrl != null) { 218 return MDNLink({ 219 url: props.mdnUrl, 220 title: props.label, 221 }); 222 } 223 const onClick = event => { 224 if (props.callback) { 225 const result = props.callback(this, props, event.target); 226 if (!result) { 227 this.close(notification); 228 } 229 event.stopPropagation(); 230 } 231 }; 232 233 return button( 234 { 235 key: props.label, 236 className: "notificationButton", 237 accesskey: props.accesskey, 238 onClick, 239 }, 240 props.label 241 ); 242 } 243 244 /** 245 * Render a notification. 246 */ 247 renderNotification(notification) { 248 return div( 249 { 250 key: notification.value, 251 className: "notification", 252 "data-key": notification.value, 253 "data-type": notification.type, 254 }, 255 div( 256 { className: "notificationInner" }, 257 div({ 258 className: "messageImage", 259 "data-type": notification.type, 260 }), 261 span( 262 { 263 className: "messageText", 264 title: notification.label, 265 }, 266 notification.label 267 ), 268 notification.buttons.map(props => 269 this.renderButton(props, notification) 270 ), 271 this.props.displayCloseButton 272 ? button({ 273 className: "messageCloseButton", 274 title: this.props.closeButtonTooltip, 275 onClick: this.close.bind(this, notification), 276 }) 277 : null 278 ) 279 ); 280 } 281 282 /** 283 * Render the top (highest priority) notification. Only one 284 * notification is rendered at a time. 285 */ 286 render() { 287 const notifications = this.props.notifications || this.state.notifications; 288 const notification = getHighestPriorityNotification(notifications); 289 const content = notification ? this.renderNotification(notification) : null; 290 291 const classNames = ["notificationbox"]; 292 if (this.props.wrapping) { 293 classNames.push("wrapping"); 294 } 295 296 if (this.props.displayBorderBottom) { 297 classNames.push("border-bottom"); 298 } 299 300 if (this.props.displayBorderTop) { 301 classNames.push("border-top"); 302 } 303 304 return div( 305 { 306 className: classNames.join(" "), 307 id: this.props.id, 308 }, 309 content 310 ); 311 } 312 } 313 314 // Helpers 315 316 /** 317 * Create a new notification. If another notification is already present with 318 * a higher priority, the new notification will be added behind it. 319 * See `propTypes` for arguments description. 320 */ 321 function appendNotification(state, props) { 322 const { label, value, image, priority, buttons, eventCallback } = props; 323 324 // Priority level must be within expected interval 325 // (see priority levels at the top of this file). 326 if ( 327 priority < PriorityLevels.PRIORITY_INFO_LOW || 328 priority > PriorityLevels.PRIORITY_CRITICAL_BLOCK 329 ) { 330 throw new Error("Invalid notification priority " + priority); 331 } 332 333 // Custom image URL is not supported yet. 334 if (image) { 335 throw new Error("Custom image URL is not supported yet"); 336 } 337 338 let type = "warning"; 339 if (priority == PriorityLevels.PRIORITY_NEW) { 340 type = "new"; 341 } else if (priority >= PriorityLevels.PRIORITY_CRITICAL_LOW) { 342 type = "critical"; 343 } else if (priority <= PriorityLevels.PRIORITY_INFO_HIGH) { 344 type = "info"; 345 } 346 347 if (!state.notifications) { 348 state.notifications = new Map(); 349 } 350 351 const notifications = new Map(state.notifications); 352 notifications.set(value, { 353 label, 354 value, 355 image, 356 priority, 357 type, 358 buttons: Array.isArray(buttons) ? buttons : [], 359 eventCallback, 360 }); 361 362 return { 363 notifications, 364 }; 365 } 366 367 function getNotificationWithValue(notifications, value) { 368 return notifications ? notifications.get(value) : null; 369 } 370 371 function removeNotificationWithValue(notifications, value) { 372 const newNotifications = new Map(notifications); 373 newNotifications.delete(value); 374 375 return { 376 notifications: newNotifications, 377 }; 378 } 379 380 function getHighestPriorityNotification(notifications) { 381 if (!notifications) { 382 return null; 383 } 384 385 let currentNotification = null; 386 // High priorities must be on top. 387 for (const [, notification] of notifications) { 388 if ( 389 !currentNotification || 390 notification.priority > currentNotification.priority 391 ) { 392 currentNotification = notification; 393 } 394 } 395 396 return currentNotification; 397 } 398 399 module.exports.NotificationBox = NotificationBox; 400 module.exports.PriorityLevels = PriorityLevels; 401 module.exports.appendNotification = appendNotification; 402 module.exports.getNotificationWithValue = getNotificationWithValue; 403 module.exports.removeNotificationWithValue = removeNotificationWithValue;