Integration with Backbone.stickit + rework of nodes statistics using stickit

Change-Id: Ic196115d4fc1f081154cea711afc97448665a629
This commit is contained in:
Vitaly Kramskikh 2013-10-23 16:34:03 +04:00
parent 179f65e28f
commit 4a1ae9f867
6 changed files with 533 additions and 18 deletions

View File

@ -160,6 +160,10 @@ function(models, commonViews, ClusterPage, NodesTab, ClustersPage, ReleasesPage,
return originalSync.apply(this, args);
};
window.Coccyx.addTearDownCallback(function() {
this.unstickit();
});
window.isWebkit = navigator.userAgent.indexOf('AppleWebKit/') !== -1;
var app = new AppRouter();

View File

@ -0,0 +1,500 @@
(function(Backbone) {
var $ = Backbone.$ || window.jQuery || window.Zepto;
// Backbone.Stickit Namespace
// --------------------------
Backbone.Stickit = {
_handlers: [],
addHandler: function(handlers) {
// Fill-in default values.
handlers = _.map(_.flatten([handlers]), function(handler) {
return _.extend({
updateModel: true,
updateView: true,
updateMethod: 'text'
}, handler);
});
this._handlers = this._handlers.concat(handlers);
}
};
// Backbone.View Mixins
// --------------------
_.extend(Backbone.View.prototype, {
// Collection of model event bindings.
// [{model,event,fn}, ...]
_modelBindings: null,
// Unbind the model and event bindings from `this._modelBindings` and
// `this.$el`. If the optional `model` parameter is defined, then only
// delete bindings for the given `model` and its corresponding view events.
unstickit: function(model) {
var models = [];
_.each(this._modelBindings, function(binding, i) {
if (model && binding.model !== model) return false;
binding.model.off(binding.event, binding.fn);
models.push(binding.model);
delete this._modelBindings[i];
}, this);
// Trigger an event for each model that was unbound.
_.invoke(_.uniq(models), 'trigger', 'stickit:unstuck', this.cid);
// Cleanup the null values.
this._modelBindings = _.compact(this._modelBindings);
this.$el.off('.stickit' + (model ? '.' + model.cid : ''));
},
// Using `this.bindings` configuration or the `optionalBindingsConfig`, binds `this.model`
// or the `optionalModel` to elements in the view.
stickit: function(optionalModel, optionalBindingsConfig) {
var model = optionalModel || this.model,
namespace = '.stickit.' + model.cid,
bindings = optionalBindingsConfig || this.bindings || {};
this._modelBindings || (this._modelBindings = []);
this.unstickit(model);
// Iterate through the selectors in the bindings configuration and configure
// the various options for each field.
_.each(bindings, function(v, selector) {
var $el, options, modelAttr, config,
binding = bindings[selector] || {},
bindId = _.uniqueId();
// Support ':el' selector - special case selector for the view managed delegate.
$el = selector === ':el' ? this.$el : this.$(selector);
// Fail fast if the selector didn't match an element.
if (!$el.length) return;
// Allow shorthand setting of model attributes - `'selector':'observe'`.
if (_.isString(binding)) binding = {observe:binding};
// Handle case where `observe` is in the form of a function.
if (_.isFunction(binding.observe)) binding.observe = binding.observe.call(this);
config = getConfiguration($el, binding);
modelAttr = config.observe;
// Create the model set options with a unique `bindId` so that we
// can avoid double-binding in the `change:attribute` event handler.
config.bindId = bindId;
// Add a reference to the view for handlers of stickitChange events
config.view = this;
options = _.extend({stickitChange:config}, config.setOptions);
initializeAttributes(this, $el, config, model, modelAttr);
initializeVisible(this, $el, config, model, modelAttr);
if (modelAttr) {
// Setup one-way, form element to model, bindings.
_.each(config.events, function(type) {
var event = type + namespace;
var method = function(event) {
var val = config.getVal.call(this, $el, event, config, _.rest(arguments));
// Don't update the model if false is returned from the `updateModel` configuration.
if (evaluateBoolean(this, config.updateModel, val, config))
setAttr(model, modelAttr, val, options, this, config);
};
if (selector === ':el') this.$el.on(event, method);
else this.$el.on(event, selector, method);
}, this);
// Setup a `change:modelAttr` observer to keep the view element in sync.
// `modelAttr` may be an array of attributes or a single string value.
_.each(_.flatten([modelAttr]), function(attr) {
observeModelEvent(model, this, 'change:'+attr, function(model, val, options) {
var changeId = options && options.stickitChange && options.stickitChange.bindId || null;
if (changeId !== bindId)
updateViewBindEl(this, $el, config, getAttr(model, modelAttr, config, this), model);
});
}, this);
updateViewBindEl(this, $el, config, getAttr(model, modelAttr, config, this), model, true);
}
model.on('stickit:unstuck', function(cid) {
if (cid === this.cid) applyViewFn(this, config.destroy, $el, model, config);
}, this);
// After each binding is setup, call the `initialize` callback.
applyViewFn(this, config.initialize, $el, model, config);
}, this);
// Wrap `view.remove` to unbind stickit model and dom events.
var remove = this.remove;
this.remove = function() {
var ret = this;
this.unstickit();
if (remove) ret = remove.apply(this, _.rest(arguments));
return ret;
};
}
});
// Helpers
// -------
// Evaluates the given `path` (in object/dot-notation) relative to the given
// `obj`. If the path is null/undefined, then the given `obj` is returned.
var evaluatePath = function(obj, path) {
var parts = (path || '').split('.');
var result = _.reduce(parts, function(memo, i) { return memo[i]; }, obj);
return result == null ? obj : result;
};
// If the given `fn` is a string, then view[fn] is called, otherwise it is
// a function that should be executed.
var applyViewFn = function(view, fn) {
if (fn) return (_.isString(fn) ? view[fn] : fn).apply(view, _.rest(arguments, 2));
};
var getSelectedOption = function($select) { return $select.find('option').not(function(){ return !this.selected; }); };
// Given a function, string (view function reference), or a boolean
// value, returns the truthy result. Any other types evaluate as false.
var evaluateBoolean = function(view, reference) {
if (_.isBoolean(reference)) return reference;
else if (_.isFunction(reference) || _.isString(reference))
return applyViewFn.apply(this, arguments);
return false;
};
// Setup a model event binding with the given function, and track the event
// in the view's _modelBindings.
var observeModelEvent = function(model, view, event, fn) {
model.on(event, fn, view);
view._modelBindings.push({model:model, event:event, fn:fn});
};
// Prepares the given `val`ue and sets it into the `model`.
var setAttr = function(model, attr, val, options, context, config) {
if (config.onSet) val = applyViewFn(context, config.onSet, val, config);
model.set(attr, val, options);
};
// Returns the given `attr`'s value from the `model`, escaping and
// formatting if necessary. If `attr` is an array, then an array of
// respective values will be returned.
var getAttr = function(model, attr, config, context) {
var val,
retrieveVal = function(field) {
return model[config.escape ? 'escape' : 'get'](field);
},
sanitizeVal = function(val) {
return val == null ? '' : val;
};
val = _.isArray(attr) ? _.map(attr, retrieveVal) : retrieveVal(attr);
if (config.onGet) val = applyViewFn(context, config.onGet, val, config);
return _.isArray(val) ? _.map(val, sanitizeVal) : sanitizeVal(val);
};
// Find handlers in `Backbone.Stickit._handlers` with selectors that match
// `$el` and generate a configuration by mixing them in the order that they
// were found with the given `binding`.
var getConfiguration = Backbone.Stickit.getConfiguration = function($el, binding) {
var handlers = [{
updateModel: false,
updateMethod: 'text',
update: function($el, val, m, opts) { if ($el[opts.updateMethod]) $el[opts.updateMethod](val); },
getVal: function($el, e, opts) { return $el[opts.updateMethod](); }
}];
handlers = handlers.concat(_.filter(Backbone.Stickit._handlers, function(handler) {
return $el.is(handler.selector);
}));
handlers.push(binding);
var config = _.extend.apply(_, handlers);
// `updateView` is defaulted to false for configutrations with
// `visible`; otherwise, `updateView` is defaulted to true.
if (config.visible && !_.has(config, 'updateView')) config.updateView = false;
else if (!_.has(config, 'updateView')) config.updateView = true;
delete config.selector;
return config;
};
// Setup the attributes configuration - a list that maps an attribute or
// property `name`, to an `observe`d model attribute, using an optional
// `onGet` formatter.
//
// attributes: [{
// name: 'attributeOrPropertyName',
// observe: 'modelAttrName'
// onGet: function(modelAttrVal, modelAttrName) { ... }
// }, ...]
//
var initializeAttributes = function(view, $el, config, model, modelAttr) {
var props = ['autofocus', 'autoplay', 'async', 'checked', 'controls', 'defer', 'disabled', 'hidden', 'loop', 'multiple', 'open', 'readonly', 'required', 'scoped', 'selected'];
_.each(config.attributes || [], function(attrConfig) {
var lastClass = '', observed, updateAttr;
attrConfig = _.clone(attrConfig);
observed = attrConfig.observe || (attrConfig.observe = modelAttr),
updateAttr = function() {
var updateType = _.indexOf(props, attrConfig.name, true) > -1 ? 'prop' : 'attr',
val = getAttr(model, observed, attrConfig, view);
// If it is a class then we need to remove the last value and add the new.
if (attrConfig.name === 'class') {
$el.removeClass(lastClass).addClass(val);
lastClass = val;
}
else $el[updateType](attrConfig.name, val);
};
_.each(_.flatten([observed]), function(attr) {
observeModelEvent(model, view, 'change:' + attr, updateAttr);
});
updateAttr();
});
};
// If `visible` is configured, then the view element will be shown/hidden
// based on the truthiness of the modelattr's value or the result of the
// given callback. If a `visibleFn` is also supplied, then that callback
// will be executed to manually handle showing/hiding the view element.
//
// observe: 'isRight',
// visible: true, // or function(val, options) {}
// visibleFn: function($el, isVisible, options) {} // optional handler
//
var initializeVisible = function(view, $el, config, model, modelAttr) {
if (config.visible == null) return;
var visibleCb = function() {
var visible = config.visible,
visibleFn = config.visibleFn,
val = getAttr(model, modelAttr, config, view),
isVisible = !!val;
// If `visible` is a function then it should return a boolean result to show/hide.
if (_.isFunction(visible) || _.isString(visible)) isVisible = applyViewFn(view, visible, val, config);
// Either use the custom `visibleFn`, if provided, or execute the standard show/hide.
if (visibleFn) applyViewFn(view, visibleFn, $el, isVisible, config);
else {
$el.toggle(isVisible);
}
};
_.each(_.flatten([modelAttr]), function(attr) {
observeModelEvent(model, view, 'change:' + attr, visibleCb);
});
visibleCb();
};
// Update the value of `$el` using the given configuration and trigger the
// `afterUpdate` callback. This action may be blocked by `config.updateView`.
//
// update: function($el, val, model, options) {}, // handler for updating
// updateView: true, // defaults to true
// afterUpdate: function($el, val, options) {} // optional callback
//
var updateViewBindEl = function(view, $el, config, val, model, isInitializing) {
if (!evaluateBoolean(view, config.updateView, val, config)) return;
applyViewFn(view, config.update, $el, val, model, config);
if (!isInitializing) applyViewFn(view, config.afterUpdate, $el, val, config);
};
// Default Handlers
// ----------------
Backbone.Stickit.addHandler([{
selector: '[contenteditable="true"]',
updateMethod: 'html',
events: ['input', 'change']
}, {
selector: 'input',
events: ['propertychange', 'input', 'change'],
update: function($el, val) { $el.val(val); },
getVal: function($el) {
return $el.val();
}
}, {
selector: 'textarea',
events: ['propertychange', 'input', 'change'],
update: function($el, val) { $el.val(val); },
getVal: function($el) { return $el.val(); }
}, {
selector: 'input[type="radio"]',
events: ['change'],
update: function($el, val) {
$el.filter('[value="'+val+'"]').prop('checked', true);
},
getVal: function($el) {
return $el.filter(':checked').val();
}
}, {
selector: 'input[type="checkbox"]',
events: ['change'],
update: function($el, val, model, options) {
if ($el.length > 1) {
// There are multiple checkboxes so we need to go through them and check
// any that have value attributes that match what's in the array of `val`s.
val || (val = []);
_.each($el, function(el) {
if (_.indexOf(val, $(el).val()) > -1) $(el).prop('checked', true);
else $(el).prop('checked', false);
});
} else {
if (_.isBoolean(val)) $el.prop('checked', val);
else $el.prop('checked', val === $el.val());
}
},
getVal: function($el) {
var val;
if ($el.length > 1) {
val = _.reduce($el, function(memo, el) {
if ($(el).prop('checked')) memo.push($(el).val());
return memo;
}, []);
} else {
val = $el.prop('checked');
// If the checkbox has a value attribute defined, then
// use that value. Most browsers use "on" as a default.
var boxval = $el.val();
if (boxval !== 'on' && boxval != null) {
val = val ? $el.val() : null;
}
}
return val;
}
}, {
selector: 'select',
events: ['change'],
update: function($el, val, model, options) {
var optList,
selectConfig = options.selectOptions,
list = selectConfig && selectConfig.collection || undefined,
isMultiple = $el.prop('multiple');
// If there are no `selectOptions` then we assume that the `<select>`
// is pre-rendered and that we need to generate the collection.
if (!selectConfig) {
selectConfig = {};
var getList = function($el) {
return $el.map(function() {
return {value:this.value, label:this.text};
}).get();
};
if ($el.find('optgroup').length) {
list = {opt_labels:[]};
// Search for options without optgroup
if ($el.find('> option').length) {
list.opt_labels.push(undefined);
_.each($el.find('> option'), function(el) {
list[undefined] = getList($(el));
});
}
_.each($el.find('optgroup'), function(el) {
var label = $(el).attr('label');
list.opt_labels.push(label);
list[label] = getList($(el).find('option'));
});
} else {
list = getList($el.find('option'));
}
}
// Fill in default label and path values.
selectConfig.valuePath = selectConfig.valuePath || 'value';
selectConfig.labelPath = selectConfig.labelPath || 'label';
var addSelectOptions = function(optList, $el, fieldVal) {
_.each(optList, function(obj) {
var option = $('<option/>'), optionVal = obj;
var fillOption = function(text, val) {
option.text(text);
optionVal = val;
// Save the option value as data so that we can reference it later.
option.data('stickit_bind_val', optionVal);
if (!_.isArray(optionVal) && !_.isObject(optionVal)) option.val(optionVal);
};
if (obj === '__default__')
fillOption(selectConfig.defaultOption.label, selectConfig.defaultOption.value);
else
fillOption(evaluatePath(obj, selectConfig.labelPath), evaluatePath(obj, selectConfig.valuePath));
// Determine if this option is selected.
if (!isMultiple && optionVal != null && fieldVal != null && optionVal === fieldVal || (_.isObject(fieldVal) && _.isEqual(optionVal, fieldVal)))
option.prop('selected', true);
else if (isMultiple && _.isArray(fieldVal)) {
_.each(fieldVal, function(val) {
if (_.isObject(val)) val = evaluatePath(val, selectConfig.valuePath);
if (val === optionVal || (_.isObject(val) && _.isEqual(optionVal, val)))
option.prop('selected', true);
});
}
$el.append(option);
});
};
$el.html('');
// The `list` configuration is a function that returns the options list or a string
// which represents the path to the list relative to `window` or the view/`this`.
var evaluate = function(view, list) {
var context = window;
if (list.indexOf('this.') === 0) context = view;
list = list.replace(/^[a-z]*\.(.+)$/, '$1');
return evaluatePath(context, list);
};
if (_.isString(list)) optList = evaluate(this, list);
else if (_.isFunction(list)) optList = applyViewFn(this, list, $el, options);
else optList = list;
// Support Backbone.Collection and deserialize.
if (optList instanceof Backbone.Collection) optList = optList.toJSON();
if (selectConfig.defaultOption) {
addSelectOptions(["__default__"], $el)
}
if (_.isArray(optList)) {
addSelectOptions(optList, $el, val);
} else if (optList.opt_labels) {
// To define a select with optgroups, format selectOptions.collection as an object
// with an 'opt_labels' property, as in the following:
//
// {
// 'opt_labels': ['Looney Tunes', 'Three Stooges'],
// 'Looney Tunes': [{id: 1, name: 'Bugs Bunny'}, {id: 2, name: 'Donald Duck'}],
// 'Three Stooges': [{id: 3, name : 'moe'}, {id: 4, name : 'larry'}, {id: 5, name : 'curly'}]
// }
//
_.each(optList.opt_labels, function(label) {
var $group = $('<optgroup/>').attr('label', label);
addSelectOptions(optList[label], $group, val);
$el.append($group);
});
// With no 'opt_labels' parameter, the object is assumed to be a simple value-label map.
// Pass a selectOptions.comparator to override the default order of alphabetical by label.
} else {
var opts = [], opt;
for (var i in optList) {
opt = {};
opt[selectConfig.valuePath] = i;
opt[selectConfig.labelPath] = optList[i];
opts.push(opt);
}
addSelectOptions(_.sortBy(opts, selectConfig.comparator || selectConfig.labelPath), $el, val);
}
},
getVal: function($el) {
var val;
if ($el.prop('multiple')) {
val = $(getSelectedOption($el).map(function() {
return $(this).data('stickit_bind_val');
})).get();
} else {
val = getSelectedOption($el).data('stickit_bind_val');
}
return val;
}
}]);
})(Backbone);

0
nailgun/static/js/libs/retina.js Executable file → Normal file
View File

View File

@ -26,6 +26,7 @@ requirejs.config({
utils: 'js/utils',
lodash: 'js/libs/lodash',
backbone: 'js/libs/backbone',
stickit: 'js/libs/backbone.stickit',
coccyx: 'js/libs/coccyx',
bootstrap: 'js/libs/bootstrap.min',
text: 'js/libs/text',
@ -43,8 +44,11 @@ requirejs.config({
deps: ['lodash', 'jquery'],
exports: 'Backbone'
},
stickit: {
deps: ['backbone']
},
coccyx: {
deps: ['lodash', 'backbone']
deps: ['backbone']
},
bootstrap: {
deps: ['jquery']
@ -62,7 +66,7 @@ requirejs.config({
deps: ['jquery']
},
app: {
deps: ['jquery', 'lodash', 'backbone', 'coccyx', 'bootstrap', 'retina', 'jquery-checkbox', 'jquery-timeout', 'jquery-ui', 'jquery-autoNumeric']
deps: ['jquery', 'lodash', 'backbone', 'stickit', 'coccyx', 'bootstrap', 'retina', 'jquery-checkbox', 'jquery-timeout', 'jquery-ui', 'jquery-autoNumeric']
}
}
});

View File

@ -99,14 +99,29 @@ function(utils, models, dialogViews, navbarTemplate, nodesStatsTemplate, notific
views.NodesStats = Backbone.View.extend({
template: _.template(nodesStatsTemplate),
bindings: {
'.total-nodes-count': 'total',
'.total-nodes-title': {
observe: 'total',
onGet: 'formatTitle',
updateMethod: 'html'
},
'.unallocated-nodes-count': 'unallocated',
'.unallocated-nodes-title': {
observe: 'unallocated',
onGet: 'formatTitle',
updateMethod: 'html'
}
},
formatTitle: function(value, options) {
return !_.isUndefined(value) ? options.observe + '<br>' + 'node' + (value == 1 ? '' : 's') : '';
},
initialize: function(options) {
_.defaults(this, options);
this.statistics.on('change', this.render, this);
},
render: function() {
if (this.statistics.deferred.state() == 'resolved') {
this.$el.html(this.template({stats: this.statistics}));
}
this.$el.html(this.template({stats: this.statistics}));
this.stickit(this.statistics);
return this;
}
});

View File

@ -1,14 +1,6 @@
<div class="statistic">
<div class="stat-count">
<%= stats.get('total') %>
</div>
<div class="stat-title">
total<br/>node<%= stats.get('total') == 1 ? '' : 's' %>
</div>
<div class="stat-count">
<%= stats.get('unallocated') %>
</div>
<div class="stat-title">
unallocated<br/>node<%= stats.get('unallocated') == 1 ? '' : 's' %>
</div>
<div class="stat-count total-nodes-count"></div>
<div class="stat-title total-nodes-title"></div>
<div class="stat-count unallocated-nodes-count"></div>
<div class="stat-title unallocated-nodes-title"></div>
</div>