Initial phase of notification service.

This commit is contained in:
Spencer Alger 2014-03-12 10:40:18 -07:00
parent b4a04b3a80
commit fb0814fcfd
17 changed files with 820 additions and 40 deletions

View file

@ -22,9 +22,12 @@
</li>
</ul>
<ul class="nav navbar-nav pull-right">
<li>
<a ng-click="configure()"><i class="fa fa-gear"></i></a>
<li ng-repeat="level in levels">
<a ng-click="notifTest(level.name)" tooltip="{{level.name}}">
<i class="fa" ng-class="'fa-' + level.icon"></i>
</a>
</li>
<li><a ng-click="configure()" tooltip="Configure Kibana"><i class="fa fa-gear"></i></a></li>
</ul>
</div>
</nav>
@ -34,7 +37,7 @@
config-submit="saveOpts">
</config>
<div class="application" kbn-view></div>
</div>
<div kbn-notifications list="notifList"></div>
</body>
</html>

View file

@ -6,10 +6,19 @@ define(function (require) {
require('services/config');
require('services/courier');
require('directives/view');
require('angular-bootstrap');
require('modules')
.get('kibana/controllers')
.controller('kibana', function ($scope, courier, config, configFile) {
.get('kibana/controllers', ['ui.bootstrap'])
.config(function ($tooltipProvider) {
$tooltipProvider.options({
placement: 'bottom',
animation: true,
popupDelay: 150,
appendToBody: false
});
})
.controller('kibana', function ($scope, courier, config, configFile, notify, $timeout) {
$scope.apps = configFile.apps;
$scope.$on('$locationChangeSuccess', function (event, uri) {
@ -38,6 +47,38 @@ define(function (require) {
$scope.configureTemplateUrl = require('text!../partials/global_config.html');
};
// expose the notification services list of notifs on the $scope so that the
// notification directive can show them on the screen
$scope.notifList = notify._notifs;
// provide alternate methods for setting timeouts, which will properly trigger digest cycles
notify._setTimerFns($timeout, $timeout.cancel);
(function TODO_REMOVE() {
// stuff for testing notifications
$scope.levels = [
{ name: 'info', icon: 'info' },
{ name: 'warning', icon: 'info-circle' },
{ name: 'error', icon: 'warning' },
{ name: 'fatal', icon: 'fire' },
];
$scope.notifTest = function (type) {
var arg = 'Something happened, just thought you should know.';
var cb;
if (type === 'fatal' || type === 'error') {
arg = new Error('Ah fuck');
}
if (type === 'error') {
cb = function (resp) {
if (resp !== 'report') return;
$timeout(function () {
notify.info('Report sent, thank you for your help.');
}, 750);
};
}
notify[type](arg, cb);
};
}());
/**
* Persist current settings
* @return {[type]} [description]
@ -71,6 +112,13 @@ define(function (require) {
config.$watch('refreshInterval', $scope.setFetchInterval);
$scope.$watch('opts.activeFetchInterval', $scope.setFetchInterval);
// setup the courier
courier.on('error', function (err) {
$scope[$scope.$$phase ? '$eval' : '$apply'](function () {
notify.error(err);
});
});
$scope.$on('application.load', function () {
courier.start();
});

View file

@ -10,6 +10,7 @@ define(function (require) {
var setup = require('./setup');
var configFile = require('../config');
var modules = require('modules');
var notify = require('notify/notify');
require('elasticsearch');
require('angular-route');
@ -46,8 +47,14 @@ define(function (require) {
return 'apps/' + app.id + '/index';
})), function bootstrap() {
$(function () {
angular.bootstrap(document, ['kibana']);
$(document.body).children().show();
notify.lifecycle('bootstrap');
angular
.bootstrap(document, ['kibana'])
.invoke(function (notify) {
notify.lifecycle('bootstrap', true);
$(document.body).children().show();
});
});
});

View file

@ -0,0 +1,135 @@
define(function (require) {
var notify = require('modules').get('notify');
var _ = require('lodash');
var $ = require('jquery');
var MutableWatcher = require('utils/mutable_watcher');
var nextTick = require('utils/next_tick');
var defaultToastOpts = {
title: 'Notice',
lifetime: 7000
};
var transformKey = (function () {
var el = document.createElement('div');
return _.find(['transform', 'webkitTransform', 'OTransform', 'MozTransform', 'msTransform'], function (key) {
return el.style[key] !== void 0;
});
}());
notify.directive('kbnNotifications', function () {
return {
restrict: 'A',
scope: {
list: '=list'
},
template: require('text!./partials/toaster.html'),
link: function ($scope, $el) {
$el.addClass('toaster-container');
// handles recalculating positions and offsets, schedules
// recalcs and waits for 100 seconds before running again.
var layoutList = (function () {
// lazy load the $nav element
var navSelector = '.content > nav.navbar:first()';
var $nav;
// pixels between the top of list and it's attachment(nav/window)
var spacing = 10;
// was the element set to postition: fixed last calc?
var visible = false;
var recalc = function () {
// set $nav lazily
if (!$nav || !$nav.length) $nav = $(navSelector);
// if we can't find the nav, don't display the list
if (!$nav.length) return;
// the top point at which the list should be secured
var fixedTop = $nav.height();
// height of the section at the top of the page that is hidden
var hiddenBottom = document.body.scrollTop;
var top, left, css = {
visibility: 'visible'
};
if (hiddenBottom > fixedTop) {
// if we are already fixed, no reason to set the styles again
css.position = 'fixed';
top = spacing;
} else {
css.position = 'absolute';
top = fixedTop + spacing;
}
// calculate the expected left value (keep it centered)
left = Math.floor((document.body.scrollWidth - $el.width()) / 2);
css[transformKey] = 'translateX(' + Math.round(left) + 'px) translateY(' + Math.round(top) + 'px)';
if (transformKey !== 'msTransform') {
// The Z transform will keep this in the GPU (faster, and prevents artifacts),
// but IE9 doesn't support 3d transforms and will choke.
css[transformKey] += ' translateZ(0)';
}
$el.css(css);
};
// track the already scheduled recalcs
var timeoutId;
var clearSchedule = function () {
timeoutId = null;
};
var schedule = function () {
if (timeoutId) return;
else recalc();
timeoutId = setTimeout(clearSchedule, 25);
};
// call to remove the $el from the view
schedule.hide = function () {
$el.css('visibility', 'hidden');
visible = false;
};
return schedule;
}());
function listen(off) {
$(window)[off ? 'off' : 'on']('resize scroll', layoutList);
}
var wat = new MutableWatcher({
$scope: $scope,
expression: 'list',
type: 'collection'
}, showList);
function showList(list) {
if (list && list.length) {
listen();
wat.set(hideList);
// delay so that angular has time to update the DOM
nextTick(layoutList);
}
}
function hideList(list) {
if (!list || !list.length) {
listen(true);
wat.set(showList);
layoutList.hide();
}
}
$scope.$on('$destoy', _.partial(listen, true));
}
};
});
});

View file

@ -0,0 +1,72 @@
define(function (require) {
var errors = {};
var _ = require('lodash');
var inherits = require('utils/inherits');
var canStack = (function () {
var err = new Error();
return !!err.stack;
}());
// abstract error class
function KibanaError(msg, constructor) {
this.message = msg;
Error.call(this, this.message);
if (!this.stack) {
if (Error.captureStackTrace) {
Error.captureStackTrace(this, constructor || KibanaError);
} else if (canStack) {
this.stack = (new Error()).stack;
} else {
this.stack = '';
}
}
}
errors.KibanaError = KibanaError;
inherits(KibanaError, Error);
/**
* Map of error text for different error types
* @type {Object}
*/
var requireTypeText = {
timeout: 'a network timeout',
nodefine: 'an invalid module definition',
scripterror: 'a generic script error'
};
/**
* ScriptLoadFailure error class for handling requirejs load failures
* @param {String} [msg] -
*/
errors.ScriptLoadFailure = function ScriptLoadFailure(err) {
var explain = requireTypeText[err.requireType] || err.requireType || 'an unknown error';
this.stack = err.stack;
var modules = err.requireModules;
if (_.isArray(modules) && modules.length > 0) {
modules = modules.map(JSON.stringify);
if (modules.length > 1) {
modules = modules.slice(0, -1).join(', ') + ' and ' + modules.slice(-1)[0];
} else {
modules = modules[0];
}
modules += ' modules';
}
if (!modules || !modules.length) {
modules = 'unknown modules';
}
KibanaError.call(this,
'Unable to load ' + modules + ' because of ' + explain + '.',
errors.ScriptLoadFailure);
};
inherits(errors.ScriptLoadFailure, KibanaError);
return errors;
});

View file

@ -0,0 +1,163 @@
define(function (require) {
var _ = require('lodash');
var $ = require('jquery');
var fatalToastTemplate = (function lazyTemplate(tmpl) {
var compiled;
return function (vars) {
compiled = compiled || _.template(tmpl);
return compiled(vars);
};
}(require('text!./partials/fatal.html')));
/**
* Functionality to check that
*/
function NotifyManager() {
var applicationBooted;
var notifs = this._notifs = [];
var setTO = setTimeout;
var clearTO = clearTimeout;
function now() {
if (window.performance && window.performance.now) {
return window.performance.now();
}
return Date.now();
}
var log = (typeof KIBANA_DIST === 'undefined') ? _.bindKey(console, 'log') : _.noop;
function closeNotif(cb, key) {
return function () {
// this === notif
var i = notifs.indexOf(this);
if (i !== -1) notifs.splice(i, 1);
if (this.timerId) this.timerId = clearTO(this.timerId);
if (typeof cb === 'function') cb(key);
};
}
function add(notif, cb) {
if (notif.lifetime !== Infinity) {
notif.timerId = setTO(function () {
closeNotif(cb, 'ignore').call(notif);
}, notif.lifetime);
}
if (notif.actions) {
notif.actions.forEach(function (action) {
notif[action] = closeNotif(cb, action);
});
}
notifs.push(notif);
}
this._setTimerFns = function (set, clear) {
setTO = set;
clearTO = clear;
};
/**
* Notify the serivce of app lifecycle events
* @type {[type]}
*/
var lifecycleEvents = window.kibanaLifecycleEvents = {};
this.lifecycle = function (name, success) {
var status;
if (name === 'bootstrap' && success === true) applicationBooted = true;
if (success === void 0) {
// start
lifecycleEvents[name] = now();
} else {
// end
if (success) {
lifecycleEvents[name] = now() - (lifecycleEvents[name] || 0);
status = lifecycleEvents[name].toFixed(2) + ' ms';
} else {
lifecycleEvents[name] = false;
status = 'failure';
}
}
log('KBN: ' + name + (status ? ' - ' + status : ''));
};
/**
* Kill the page, and display an error
* @param {Error} err - The fatal error that occured
*/
this.fatal = function (err) {
var html = fatalToastTemplate({
msg: err instanceof Error ? err.message : err,
stack: err.stack
});
var $container = $('#fatal-splash-screen');
if ($container.size()) {
$container.append(html);
return;
}
$container = $();
// in case the app has not completed boot
$(document.body)
.removeAttr('ng-cloak')
.html('<div id="fatal-splash-screen" class="container-fuild">' + html + '</div>');
};
/**
* Alert the user of an error that occured
* @param {Error|String} err
*/
this.error = function (err, cb) {
add({
type: 'danger',
content: err instanceof Error ? err.message : err,
icon: 'warning',
title: 'Error',
lifetime: Infinity,
actions: ['report', 'accept']
}, cb);
};
/**
* Warn the user abort something
* @param {[type]} msg [description]
* @return {[type]} [description]
*/
this.warning = function (msg, cb) {
add({
type: 'warning',
content: msg,
icon: 'warning',
title: 'Warning',
lifetime: 7000,
actions: ['accept']
}, cb);
};
/**
* Display a debug message
* @param {String} msg [description]
* @return {[type]} [description]
*/
this.info = function (msg, cb) {
add({
type: 'info',
content: msg,
icon: 'info-circle',
title: 'Debug',
lifetime: 7000,
actions: ['accept']
}, cb);
};
}
return NotifyManager;
});

View file

@ -0,0 +1,93 @@
define(function (require) {
var _ = require('lodash');
var nextTick = require('utils/next_tick');
var $ = require('jquery');
var modules = require('modules');
var module = modules.get('notify');
var errors = require('./errors');
var NotifyManager = require('./manager');
var manager = new NotifyManager();
require('./directives');
module.service('notify', function () {
var service = this;
// modify the service to have bound proxies to the manager
_.forOwn(manager, function (val, key) {
service[key] = typeof val === 'function' ? _.bindKey(manager, key) : val;
});
});
/**
* Global Angular uncaught exception handler
*/
modules
.get('exceptionOverride')
.factory('$exceptionHandler', function () {
return function (exception, cause) {
manager.fatal(exception, cause);
};
});
/**
* Global Require.js exception handler
*/
window.requirejs.onError = function (err) {
manager.fatal(new errors.ScriptLoadFailure(err));
};
window.onerror = function (err, url, line) {
manager.fatal(new Error(err + ' (' + url + ':' + line + ')'));
return true;
};
// function onTabFocus(onChange) {
// var current = true;
// // bind each individually
// var elem = window;
// var focus = 'focus';
// var blur = 'blur';
// if (/*@cc_on!@*/false) { // check for Internet Explorer
// elem = document;
// focus = 'focusin';
// blur = 'focusout';
// }
// function handler(event) {
// var state;
// if (event.type === focus) {
// state = true;
// } else if (event.type === blur) {
// state = false;
// } else {
// return;
// }
// if (current !== state) {
// current = state;
// onChange(current);
// }
// }
// elem.addEventListener(focus, handler);
// elem.addEventListener(blur, handler);
// // call the handler ASAP with the current status
// nextTick(handler, current);
// // function that the user can call to unbind this handler
// return function unBind() {
// elem.removeEventListener(focus, handler);
// elem.removeEventListener(blur, handler);
// };
// }
// onTabFocus(function (focused) {
// // log(focused ? 'welcome back' : 'good bye');
// });
return manager;
});

View file

@ -0,0 +1,20 @@
<!--
!!!!
Since fatal error could prevent angular from starting
this template is just a simple lodash template
!!!!
-->
<div class="panel panel-danger">
<div class="panel-heading">
<h1 class="panel-title">
<i class="fa fa-warning"></i> Fatal Error
<a class="pull-right" onclick="window.location.reload();" href="#">
Reload <i class="refresh fa fa-refresh"></i>
</a>
</h1>
</div>
<div class="panel-body"><%- msg %></div>
<% if (stack) { %>
<div class="panel-footer"><pre><%- stack %></pre></div>
<% } %>
</div>

View file

@ -0,0 +1,40 @@
<ul class="toaster">
<li ng-repeat="notif in list" kbn-toast notif="notif">
<div class="alert" ng-class="'alert-' + notif.type">
<table><tr>
<td>
<i class="fa" ng-class="'fa-' + notif.icon" tooltip="{{notif.title}}"></i> {{ notif.content }}
</td>
<td>
<button
type="button"
ng-if="notif.report"
class="btn"
ng-class="'btn-' + notif.type"
data-dismiss="alert"
aria-hidden="true"
ng-click="notif.report()"
>Report</button>
<button
type="button"
ng-if="notif.accept"
class="btn"
ng-class="'btn-' + notif.type"
data-dismiss="alert"
aria-hidden="true"
ng-click="notif.accept()"
>OK</button>
<button
type="button"
ng-if="notif.address"
class="btn"
ng-class="'btn-' + notif.type"
data-dismiss="alert"
aria-hidden="true"
ng-click="notif.address()"
>Fix it</button>
</td>
</tr></table>
</div>
</li>
</ul>

View file

@ -6,6 +6,7 @@ require.config({
angular: '../bower_components/angular/angular',
'angular-mocks': '../bower_components/angular-mocks/angular-mocks',
'angular-route': '../bower_components/angular-route/angular-route',
'angular-bootstrap': '../bower_components/angular-bootstrap/ui-bootstrap-tpls',
async: '../bower_components/async/lib/async',
css: '../bower_components/require-css/css',
text: '../bower_components/requirejs-text/text',
@ -23,18 +24,11 @@ require.config({
deps: ['jquery'],
exports: 'angular'
},
gridster: {
deps: ['jquery']
},
'angular-route': {
deps: ['angular']
},
'angular-mocks': {
deps: ['angular']
},
'elasticsearch': {
deps: ['angular']
}
gridster: ['jquery'],
'angular-route': ['angular'],
'angular-mocks': ['angular'],
'elasticsearch': ['angular'],
'angular-bootstrap': ['angular']
},
waitSeconds: 60
});

View file

@ -2,6 +2,7 @@ define(function (require) {
var _ = require('lodash');
var nextTick = require('utils/next_tick');
var configFile = require('../../config');
var notify = require('notify/notify');
require('services/courier');
@ -40,17 +41,17 @@ define(function (require) {
******/
function init() {
notify.lifecycle('config init');
var defer = $q.defer();
courier.fetch();
doc.fetch();
doc.on('results', function completeInit(resp) {
// ONLY ACT IF !resp.found
if (!resp.found) {
console.log('creating empty config doc');
doc.doIndex({});
return;
}
console.log('fetched config doc');
notify.lifecycle('config init', !!resp);
doc.removeListener('results', completeInit);
defer.resolve();
});
@ -87,7 +88,7 @@ define(function (require) {
// probably a horrible idea
if (!watchers[key]) watchers[key] = [];
watchers[key].push(onChange);
_notify(onChange, vals[key]);
triggerWatchers(onChange, vals[key]);
return function un$watcher() {
_.pull(watchers[key], onChange);
};
@ -143,15 +144,15 @@ define(function (require) {
*******/
function _change(key, val) {
_notify(watchers[key], val, vals[key]);
notify.lifecycle('config change: ' + key + ': ' + vals[key] + ' -> ' + val);
triggerWatchers(watchers[key], val, vals[key]);
vals[key] = val;
console.log(key, 'is now', val);
}
function _notify(fns, cur, prev) {
function triggerWatchers(fns, cur, prev) {
if ($rootScope.$$phase) {
// reschedule for next tick
nextTick(_notify, fns, cur, prev);
nextTick(triggerWatchers, fns, cur, prev);
return;
}

View file

@ -3,7 +3,7 @@ define(function (require) {
var es; // share the client amoungst all apps
require('modules')
.get('kibana/services')
.get('kibana/services', ['elasticsearch'])
.service('es', function (esFactory, configFile, $q) {
if (es) return es;

View file

@ -2,6 +2,7 @@ define(function (require) {
var angular = require('angular');
var async = require('async');
var $ = require('jquery');
var _ = require('lodash');
var configFile = require('../config');
var nextTick = require('utils/next_tick');
var modules = require('modules');
@ -15,30 +16,31 @@ define(function (require) {
return function prebootSetup(done) {
// load angular deps
require([
'kibana',
'notify/notify',
'elasticsearch',
'services/es',
'services/config',
'constants/base'
], function (kibana) {
], function (notify) {
$(function () {
// create the setup module, it should require the same things
// that kibana currently requires, which should only include the
// loaded modules
var setup = modules.get('setup', ['elasticsearch']);
var setup = modules.get('setup');
var appEl = document.createElement('div');
var kibanaIndexExists;
modules.link(setup);
setup
.value('configFile', configFile);
angular
.bootstrap(appEl, ['setup'])
.invoke(function (es, config) {
.invoke(function (es, config, notify) {
// init the setup module
async.series([
async.apply(checkForES, es),
async.apply(checkForKibanaIndex, es),
async.apply(createKibanaIndex, es),
async.apply(checkForCurrentConfigDoc, es),
@ -50,25 +52,52 @@ define(function (require) {
// linked modules should no longer depend on this module
setup.close();
console.log('booting kibana');
if (err) throw err;
return done(err);
});
});
function wrapError(err, tmpl) {
// if we pass a callback
if (typeof err === 'function') {
var cb = err; // wrap it
return function (err) {
cb(wrapError(err, tmpl));
};
}
// if an error didn't actually occur
if (!err) return void 0;
var err2 = new Error(_.template(tmpl, { configFile: configFile }));
err2.origError = err;
return err2;
}
function checkForES(es, done) {
notify.lifecycle('es check');
es.ping(function (err, alive) {
notify.lifecycle('es check', alive);
done(alive ? void 0 : new Error('Unable to connect to Elasticsearch at "' + configFile.elasticsearch + '"'));
});
}
function checkForKibanaIndex(es, done) {
notify.lifecycle('kibana index check');
es.indices.exists({
index: configFile.kibanaIndex
}, function (err, exists) {
console.log('kibana index does', (exists ? '' : 'not ') + 'exist');
notify.lifecycle('kibana index check', !!exists);
kibanaIndexExists = exists;
return done(err);
done(wrapError(err, 'Unable to check for Kibana index "<%= configFile.kibanaIndex %>"'));
});
}
// create the index if it doens't exist already
function createKibanaIndex(es, done) {
if (kibanaIndexExists) return done();
console.log('creating kibana index');
notify.lifecycle('create kibana index');
es.indices.create({
index: configFile.kibanaIndex,
body: {
@ -88,19 +117,20 @@ define(function (require) {
}
}
}
}, done);
}, function (err) {
notify.lifecycle('create kibana index', !err);
done(wrapError(err, 'Unable to create Kibana index "<%= configFile.kibanaIndex %>"'));
});
}
// if the index is brand new, no need to see if it is out of data
function checkForCurrentConfigDoc(es, done) {
if (!kibanaIndexExists) return done();
console.log('checking if migration is necessary: not implemented');
// callbacks should always be called async
nextTick(done);
}
function initConfig(config, done) {
console.log('initializing config service');
config.init().then(function () { done(); }, done);
}
});

View file

@ -0,0 +1,50 @@
#fatal-splash-screen {
margin: 15px;
}
.toaster-container {
position: absolute;
top: 0px;
left: 0px;
z-index: 1;
visibility: hidden;
width: 85%;
.toaster {
margin: 0;
padding: 0;
list-style: none;
}
.alert {
-webkit-box-shadow: 3px 0px 19px 0px rgba(50, 50, 50, 0.67);
-moz-box-shadow: 3px 0px 19px 0px rgba(50, 50, 50, 0.67);
box-shadow: 3px 0px 19px 0px rgba(50, 50, 50, 0.67);
padding: 0px 15px;
margin: 0 0 10px 0;
border: none;
button.btn {
-webkit-border-radius: 0px;
-moz-border-radius: 0px;
border-radius: 0px;
border: none;
}
table {
width: 100%;
td {
vertical-align: middle;
&:first-child {
text-align: left;
width: 80%;
}
// :not(:first-child)
text-align: right;
width: 20%;
}
}
}
}

View file

@ -6842,6 +6842,21 @@ button.close {
body {
margin: 0px;
}
.content {
position: relative;
z-index: 0;
}
.content .navbar {
position: relative;
z-index: 1;
}
.content .application {
position: relative;
z-index: 0;
}
notifications {
z-index: 1;
}
.navbar-nav li a {
cursor: pointer;
}
@ -6998,6 +7013,48 @@ kbn-table .table .table td.field-name {
kbn-table tr.even td {
background-color: #f1f1f1;
}
#fatal-splash-screen {
margin: 15px;
}
.toaster-container {
position: absolute;
top: 0px;
left: 0px;
z-index: 1;
visibility: hidden;
width: 85%;
}
.toaster-container .toaster {
margin: 0;
padding: 0;
list-style: none;
}
.toaster-container .alert {
-webkit-box-shadow: 3px 0px 19px 0px rgba(50, 50, 50, 0.67);
-moz-box-shadow: 3px 0px 19px 0px rgba(50, 50, 50, 0.67);
box-shadow: 3px 0px 19px 0px rgba(50, 50, 50, 0.67);
padding: 0px 15px;
margin: 0 0 10px 0;
border: none;
}
.toaster-container .alert button.btn {
-webkit-border-radius: 0px;
-moz-border-radius: 0px;
border-radius: 0px;
border: none;
}
.toaster-container .alert table {
width: 100%;
}
.toaster-container .alert table td {
vertical-align: middle;
text-align: right;
width: 20%;
}
.toaster-container .alert table td:first-child {
text-align: left;
width: 80%;
}
disc-field-chooser ul {
margin: 0;
padding: 0;

View file

@ -9,6 +9,24 @@ body {
margin: 0px;
}
.content {
position: relative;
z-index: 0;
.navbar {
position: relative;
z-index: 1;
}
.application {
position: relative;
z-index: 0;
}
}
notifications {
z-index: 1;
}
//== Subnav
//
// Use for adding a subnav to your app
@ -52,4 +70,5 @@ body {
}
@import "./_table.less";
@import "./_notify.less";
@import "../apps/discover/styles/main.less";

View file

@ -0,0 +1,48 @@
define(function (require) {
/**
* Helper to create a watcher than can be simply changed
* @param {[type]} opts [description]
* @param {[type]} initialFn [description]
*/
function MutableWatcher(opts, initialFn) {
opts = opts || {};
var $scope = opts.$scope;
if (!$scope) throw new TypeError('you must specify a scope.');
var expression = opts.expression;
if (!expression) throw new TypeError('you must specify an expression.');
// the watch method to call
var method = $scope[opts.type === 'collection' ? '$watchCollection' : '$watch'];
// stores the unwatch function
var unwatcher;
// change the function that the watcher triggers
function watch(watcher) {
if (typeof unwatcher === 'function') {
unwatcher();
unwatcher = null;
}
if (!watcher) return;
// include the expression as the first argument
var args = [].slice.apply(arguments);
args.unshift(expression);
// register a new unwatcher
unwatcher = method.apply($scope, args);
}
watch(initialFn);
// public API
this.set = watch;
}
return MutableWatcher;
});