'use strict';
/**
* The charm panel views implement the various things shown in the right panel
* when clicking on the "Charms" label in the title bar, or running a search.
*
* @module views
* @submodule views.charm-panel
*/
YUI.add('juju-charm-panel', function(Y) {
var views = Y.namespace('juju.views'),
utils = Y.namespace('juju.views.utils'),
models = Y.namespace('juju.models'),
// This will hold objects that can be used to detach the subscriptions
// when the charm panel is destroyed.
subscriptions = [],
// Singleton
_instance,
// See https://github.com/yui/yuidoc/issues/25 for issue tracking
// missing @function tag.
/**
* A shared listener for click events on headers that open and close
* associated divs.
*
* It expects the event target to contain an i tag used as a bootstrap
* icon, and to have a parent with the 'charm-section' class. The parent
* must contain an element with the 'collapsible' class. The i switches
* back and forth between up and down icons, and the collapsible element
* opens and closes.
*
* @method toggleSectionVisibility
* @static
* @private
* @return {undefined} Mutates only.
*/
toggleSectionVisibility = function(ev) {
var el = ev.currentTarget.ancestor('.charm-section')
.one('.collapsible'),
icon = ev.currentTarget.one('i');
// clientHeight and offsetHeight are not as reliable in tests.
if (parseInt(el.getStyle('height'), 10) === 0) {
el.show('sizeIn', {duration: 0.25, width: null});
icon.replaceClass('chevron_down', 'chevron_up');
} else {
el.hide('sizeOut', {duration: 0.25, width: null});
icon.replaceClass('chevron_up', 'chevron_down');
}
},
/**
* Given a container node and a total height available, set the height of
* a '.charm-panel' node to fill the remaining height available to it
* within the container. This expects '.charm-panel' node to possibly
* have siblings before it, but not any siblings after it.
*
* @method setScroll
* @static
* @private
* @return {undefined} Mutates only.
*/
setScroll = function(container, height) {
var scrollContainer = container.one('.charm-panel');
if (scrollContainer && height) {
var diff = scrollContainer.getY() - container.getY(),
clientDiff = (
scrollContainer.get('clientHeight') -
parseInt(scrollContainer.getComputedStyle('height'), 10)),
scrollHeight = height - diff - clientDiff - 1;
scrollContainer.setStyle('height', scrollHeight + 'px');
}
},
/**
* Given a set of entries as returned by the charm store "find"
* method (charms grouped by series), return the list filtered
* by 'filter'.
*
* @method filterEntries
* @static
* @private
* @param {Array} entries An ordered collection of groups of charms, as
* returned by the charm store "find" method.
* @param {String} filter Either 'all', 'subordinates', or 'deployed'.
* @param {Object} services The db.services model list.
* @return {Array} A filtered, grouped set of entries.
*/
filterEntries = function(entries, filter, services) {
var deployedCharms;
/**
* Filter to determine if a charm is a subordinate.
*
* @method isSubFilter
* @param {Object} charm The charm to test.
* @return {Boolean} True if the charm is a subordinate.
*/
function isSubFilter(charm) {
return !!charm.get('is_subordinate');
}
/**
* Filter to determine if a charm is the same as any
* deployed services.
*
* @method isDeployedFilter
* @param {Object} charm The charm to test.
* @return {Boolean} True if the charm matches a deployed service.
*/
function isDeployedFilter(charm) {
return deployedCharms.indexOf(charm.get('id')) !== -1;
}
var filter_fcn;
if (filter === 'all') {
return entries;
} else if (filter === 'subordinates') {
filter_fcn = isSubFilter;
} else if (filter === 'deployed') {
filter_fcn = isDeployedFilter;
if (!Y.Lang.isValue(services)) {
deployedCharms = [];
} else {
deployedCharms = services.get('charm');
}
} else {
// This case should not happen.
return entries;
}
var filtered = Y.clone(entries);
// Filter the charms based on the filter function.
filtered.forEach(function(series_group) {
series_group.charms = series_group.charms.filter(filter_fcn);
});
// Filter the series group based on the existence of any
// filtered charms.
return filtered.filter(function(series_group) {
return series_group.charms.length > 0;
});
},
/**
* Given a set of grouped entries as returned by the charm store "find"
* method, return the same data but with the charms converted into data
* objects that are more amenable to rendering with handlebars.
*
* @method makeRenderableResults
* @static
* @private
* @param {Array} entries An ordered collection of groups of charms, as
* returned by the charm store "find" method.
* @return {Array} An ordered collection of groups of charm data.
*/
makeRenderableResults = function(entries) {
return entries.map(
function(data) {
return {
series: data.series,
charms: data.charms.map(
function(charm) { return charm.getAttrs(); })
};
});
},
/**
* Given an array of interface data as stored in a charm's "required"
* and "provided" attributes, return an array of interface names.
*
* @method getInterfaces
* @static
* @private
* @param {Array} data A collection of interfaces as stored in a charm's
* "required" and "provided" attributes.
* @return {Array} A collection of interface names extracted from the
* input.
*/
getInterfaces = function(data) {
if (data) {
return Y.Array.map(
Y.Object.values(data),
function(val) { return val['interface']; });
}
return undefined;
};
/**
* Charm collection view. Show a list of charms, each clickable through
* for a description, or deployed directly with a "Deploy" button.
*
* @class CharmCollectionView
*/
var CharmCollectionView = Y.Base.create('CharmCollectionView', Y.View, [], {
template: views.Templates['charm-search-result'],
events: {
'a.charm-detail': {click: 'showDetails'},
'.charm-entry .btn.deploy': {click: 'showConfiguration'},
'.charm-entry': {
/**
* Show the charm deploy button on mouse pointer enter.
*
* @method CharmCollectionView.events.mouseenter
*/
mouseenter: function(ev) {
ev.currentTarget.all('.btn').transition({opacity: 1, duration: 0.25});
},
/**
* Hide the charm deploy button on mouse pointer leave.
*
* @method CharmCollectionView.events.mouseleave
*/
mouseleave: function(ev) {
ev.currentTarget.all('.btn').transition({opacity: 0, duration: 0.25});
}
},
'.charm-filter-picker .picker-button': {
click: 'showCharmFilterPicker'
},
'.charm-filter-picker .picker-item': {
click: 'hideCharmFilterPicker'
}
},
/**
* Set searchText to cause the results to be found and rendered.
*
* Set defaultSeries to cause all the results for the default series
* to be found and rendered.
*
* @method CharmCollectionView.initializer
*/
initializer: function() {
var self = this;
this.set('filter', 'all');
this.after('searchTextChange', function(ev) {
this.set('resultEntries', null);
if (ev.newVal) {
this.get('charmStore').find(
ev.newVal,
{ success: function(charms) {
self.set('resultEntries', charms);
},
failure: Y.bind(this._showErrors, this),
defaultSeries: this.get('defaultSeries'),
list: this.get('charms')
});
}
});
this.after('defaultSeriesChange', function(ev) {
this.set('defaultEntries', null);
if (ev.newVal) {
this.get('charmStore').find(
{series: ev.newVal, owner: 'charmers'},
{ success: function(charms) {
self.set('defaultEntries', charms);
},
failure: Y.bind(this._showErrors, this),
defaultSeries: this.get('defaultSeries'),
list: this.get('charms')
});
}
});
this.after('defaultEntriesChange', function() {
if (!this.get('searchText')) {
this.render();
}
});
this.after('resultEntriesChange', function() {
this.render();
});
this.after('heightChange', this._setScroll);
},
/**
* @method CharmCollectionView.render
*/
render: function() {
var container = this.get('container'),
searchText = this.get('searchText'),
defaultEntries = this.get('defaultEntries'),
resultEntries = this.get('resultEntries'),
rawEntries = searchText ? resultEntries : defaultEntries,
entries,
db = this.get('db') || undefined,
services = db && db.services || undefined,
filtered = {},
filters = ['all', 'subordinates', 'deployed'];
if (!rawEntries) {
return this;
}
for (var sel in filters) {
if (true) { // Avoid lint warning.
filtered[filters[sel]] = filterEntries(
rawEntries, filters[sel], services);
}
}
entries = makeRenderableResults(filtered[this.get('filter')]);
var countEntries = function(entries) {
if (!entries) {return 0;}
var lengths = entries.map(function(e) {return e.charms.length;});
// Initial value of 0 required since the array may be empty.
return lengths.reduce(function(pv, cv) {return pv + cv;}, 0);
};
container.setHTML(this.template(
{ charms: entries,
allCharmsCount: countEntries(filtered.all),
subordinateCharmsCount: countEntries(filtered.subordinates),
deployedCharmsCount: countEntries(filtered.deployed)
}));
// The picker has now been rendered generically. Based on the
// filter add the decorations.
var selected = container.one('.' + this.get('filter')),
picker = container.one('.charm-filter-picker');
selected.addClass('activetick');
picker.one('.picker-body').set('text', selected.get('text'));
// The charm details and summary are user-supplied and may be
// way too big for the fixed height cells. Sadly the best we
// can do is truncate them with ellipses.
container.all('.charm-detail').ellipsis();
container.all('.charm-summary').ellipsis({'lines': 2});
this._setScroll();
return this;
},
/**
* When the view's "height" attribute is set, adjust the internal
* scrollable div to have the appropriate height.
*
* @method _setScroll
* @protected
* @return {undefined} Mutates only.
*/
_setScroll: function() {
var container = this.get('container'),
scrollContainer = container.one('.search-result-div'),
height = this.get('height');
if (scrollContainer && height) {
scrollContainer.setStyle('height', height - 1 + 'px');
}
},
/**
* Fire an event indicating that the charm panel should switch to the
* "description" for a given charm.
*
* @method showDetails
* @param {Object} ev An event object (with a `halt` method).
* @return {undefined} Sends a signal only.
*/
showDetails: function(ev) {
ev.halt();
this.fire(
'changePanel',
{ name: 'description',
charmId: ev.target.getAttribute('href') });
},
/**
* Fire an event indicating that the charm panel should switch to the
* "configuration" for a given charm.
*
* @method showConfiguration
* @param {Object} ev An event object (with a `halt` method).
* @return {undefined} Sends a signal only.
*/
showConfiguration: function(ev) {
// Without the ev.halt the 'outside' click handler is getting
// called which immediately closes the panel.
ev.halt();
this.fire(
'changePanel',
{ name: 'configuration',
charmId: ev.currentTarget.getData('url')});
},
/**
* Create a data structure friendly to the view.
*
* @method normalizeCharms.
*/
normalizeCharms: function(charms) {
var hash = {},
defaultSeries = this.get('defaultSeries');
Y.each(charms, function(charm) {
charm.url = charm.series + '/' + charm.name;
if (charm.owner === 'charmers') {
charm.owner = null;
} else {
charm.url = '~' + charm.owner + '/' + charm.url;
}
charm.url = 'cs:' + charm.url;
if (!Y.Lang.isValue(hash[charm.series])) {
hash[charm.series] = [];
}
hash[charm.series].push(charm);
});
var series_names = Y.Object.keys(hash);
series_names.sort(function(a, b) {
if ((a === defaultSeries && b !== defaultSeries) || a > b) {
return -1;
} else if ((a !== defaultSeries && b === defaultSeries) || a < b) {
return 1;
} else {
return 0;
}
});
return Y.Array.map(series_names, function(name) {
var charms = hash[name];
charms.sort(function(a, b) {
// If !a.owner, that means it is owned by charmers.
if ((!a.owner && b.owner) || (a.owner < b.owner)) {
return -1;
} else if ((a.owner && !b.owner) || (a.owner > b.owner)) {
return 1;
} else if (a.name < b.name) {
return -1;
} else if (a.name > b.name) {
return 1;
} else {
return 0;
}
});
return {series: name, charms: hash[name]};
});
},
/**
* Find charms that match a query.
*
* @method findCharms
*/
findCharms: function(query, callback) {
var charmStore = this.get('charmStore'),
db = this.get('db');
charmStore.sendRequest({
request: 'search/json?search_text=' + query,
callback: {
'success': Y.bind(function(io_request) {
// To see an example of what is being obtained, look at
// http://jujucharms.com/search/json?search_text=mysql .
var result_set = Y.JSON.parse(
io_request.response.results[0].responseText);
console.log('results update', result_set);
callback(this.normalizeCharms(result_set.results));
}, this),
'failure': function er(e) {
console.error(e.error);
db.notifications.add(
new models.Notification({
title: 'Could not retrieve charms',
message: e.error,
level: 'error'
})
);
}}});
},
/**
* Show errors on both console and notifications.
*
* @method _showErrors
*/
_showErrors: function(e) {
console.error(e.error);
this.get('db').notifications.add(
new models.Notification({
title: 'Could not retrieve charms',
message: e.error,
level: 'error'
})
);
},
/**
* Event handler to show the charm filter picker.
*
* @method showCharmFilterPicker
* @param {Object} evt The event.
* @return {undefined} nothing.
*/
showCharmFilterPicker: function(evt) {
var container = this.get('container'),
picker = container.one('.charm-filter-picker');
picker.addClass('inactive');
picker.one('.picker-expanded').addClass('active');
},
/**
* Event handler to hide the charm filter picker
*
* @method hideCharmFilterPicker
* @param {Object} evt The event.
* @return {undefined} nothing.
*/
hideCharmFilterPicker: function(evt) {
// Set the filter and re-render the control.
var selected = evt.currentTarget;
this.set('filter', selected.getData('filter'));
this.render();
evt.halt();
}
});
views.CharmCollectionView = CharmCollectionView;
/**
* Charm description view. It describes a charm's features in detail,
* together with a "Deploy" button.
*
* @class CharmDescriptionView
*/
var CharmDescriptionView = Y.Base.create(
'CharmDescriptionView', Y.View, [views.JujuBaseView], {
template: views.Templates['charm-description'],
relatedTemplate: views.Templates['charm-description-related'],
events: {
'.charm-nav-back': {click: 'goBack'},
'.btn': {click: 'deploy'},
'.charm-section h4': {click: toggleSectionVisibility},
'a.charm-detail': {click: 'showDetails'}
},
/**
* @method CharmDescriptionView.initializer
*/
initializer: function() {
this.bindModelView(this.get('model'));
this.after('heightChange', this._setScroll);
},
/**
* @method CharmDescriptionView.render
*/
render: function() {
var container = this.get('container'),
charm = this.get('model');
if (Y.Lang.isValue(charm)) {
container.setHTML(this.template(charm.getAttrs()));
container.all('i.chevron_down').each(function(el) {
el.ancestor('.charm-section').one('div')
.setStyle('height', '0px');
});
var slot = container.one('#related-charms');
if (slot) {
this.getRelatedCharms(charm, slot);
}
} else {
container.setHTML(
'<div class="alert">Waiting on charm data...</div>');
}
this._setScroll();
return this;
},
/**
* Get related charms and render them in the provided node. Typically
* this is asynchronous, waiting on charm store results.
*
* @method getRelatedCharms
* @param {Object} charm A charm model. Finds charms related to the
* required and provided interfaces of this charm.
* @param {Object} slot An YUI node that will contain the results (using
* setHTML).
* @return {undefined} Mutates slot only.
*/
getRelatedCharms: function(charm, slot) {
var store = this.get('charmStore'),
defaultSeries = this.get('defaultSeries'),
list = this.get('charms'),
self = this,
query = {
op: 'union',
requires: getInterfaces(charm.get('provides')),
provides: getInterfaces(charm.get('requires'))
};
if (query.requires || query.provides) {
store.find(
query,
{
/**
* If the charm we searched for is still the same as the
* view's charm, ask renderRelatedCharms to render the
* results. If they differ, discard the results, because they
* are no longer relevant.
*
* @method getRelatedCharms.store.find.success
*/
success: function(related) {
if (charm === self.get('model')) {
self.renderRelatedCharms(related, slot);
}
},
/**
* If there was a failure, render it to the console and to the
* notifications section.
*
* @method getRelatedCharms.store.find.failure
*/
failure: function(e) {
console.error(e.error);
self.get('db').notifications.add(
new models.Notification({
title: 'Could not retrieve charm data',
message: e.error,
level: 'error'
})
);
},
defaultSeries: defaultSeries,
list: list
}
);
} else {
slot.setHTML('None');
}
},
/**
* Given a grouped list of related charms such as those returned by the
* charm store's "find" method, and a node into which the results should
* be rendered, render the results into HTML and sets that into the
* node.
*
* @method renderRelatedCharms
* @param {Array} related A list of grouped charms such as those
* returned by the charm store's "find" method.
* @param {Object} slot A node into which the results should be
* rendered.
* @return {undefined} Mutates only.
*/
renderRelatedCharms: function(related, slot) {
if (related.length) {
slot.setHTML(this.relatedTemplate(
{charms: makeRenderableResults(related)}));
// Make container big enough if it is open.
if (slot.get('clientHeight') > 0) {
slot.show('sizeIn', {duration: 0.25, width: null});
}
} else {
slot.setHTML('None');
}
},
/**
* When the view's "height" attribute is set, adjust the internal
* scrollable div to have the appropriate height.
*
* @method _setScroll
* @protected
* @return {undefined} Mutates only.
*/
_setScroll: function() {
setScroll(this.get('container'), this.get('height'));
},
/**
* Fire an event indicating that the charm panel should switch to the
* "charms" search result view.
*
* @method goBack
* @param {Object} ev An event object (with a "halt" method).
* @return {undefined} Sends a signal only.
*/
goBack: function(ev) {
ev.halt();
this.fire('changePanel', { name: 'charms' });
},
/**
* Fire an event indicating that the charm panel should switch to the
* "configuration" panel for the current charm.
*
* @method deploy
* @param {Object} ev An event object (with a "halt" method).
* @return {undefined} Sends a signal only.
*/
deploy: function(ev) {
ev.halt();
this.fire(
'changePanel',
{ name: 'configuration',
charmId: ev.currentTarget.getData('url')});
},
/**
* Fire an event indicating that the charm panel should switch to the
* same "description" panel but with a new charm. This is used by the
* "related charms" links.
*
* @method showDetails
* @param {Object} ev An event object (with a "halt" method).
* @return {undefined} Sends a signal only.
*/
showDetails: function(ev) {
ev.halt();
this.fire(
'changePanel',
{ name: 'description',
charmId: ev.target.getAttribute('href') });
}
});
views.CharmDescriptionView = CharmDescriptionView;
/**
* Display a charm's configuration panel. It shows editable fields for
* the charm's configuration parameters, together with a "Cancel" and
* a "Confirm" button for deployment.
*
* @class CharmConfigurationView
*/
var CharmConfigurationView = Y.Base.create(
'CharmConfigurationView', Y.View, [views.JujuBaseView], {
template: views.Templates['charm-pre-configuration'],
tooltip: null,
configFileContent: null,
/**
* @method CharmConfigurationView.initializer
*/
initializer: function() {
this.bindModelView(this.get('model'));
this.after('heightChange', this._setScroll);
this.on('panelRemoved', this._clearGhostService);
},
/**
* @method CharmConfigurationView.render
*/
render: function() {
var container = this.get('container'),
charm = this.get('model'),
config = charm && charm.get('config'),
settings = config && utils.extractServiceSettings(
config.options),
self = this;
if (charm && charm.loaded) {
container.setHTML(this.template(
{ charm: charm.getAttrs(),
settings: settings}));
// Set up entry description overlay.
this.setupOverlay(container);
// This does not work via delegation.
container.one('.charm-panel').after(
'scroll', Y.bind(this._moveTooltip, this));
// Create a 'ghost' service to represent what will be deployed.
var db = this.get('db');
// Remove the other pending services if required.
db.services.each(function(service) {
if (service.get('pending')) {
service.destroy();
}
});
var serviceCount = db.services.filter(function(service) {
return service.get('charm') === charm.get('id');
}).length + 1;
var ghostService = db.services.create({
id: '(' + charm.get('package_name') + ' ' + serviceCount + ')',
annotations: {},
pending: true,
charm: charm.get('id'),
unit_count: 0, // No units yet.
loaded: false,
config: config
});
this.set('ghostService', ghostService);
db.fire('update');
} else {
container.setHTML(
'<div class="alert">Waiting on charm data...</div>');
}
this._setScroll();
return this;
},
/**
* When the view's "height" attribute is set, adjust the internal
* scrollable div to have the appropriate height.
*
* @method _setScroll
* @protected
* @return {undefined} Mutates only.
*/
_setScroll: function() {
setScroll(this.get('container'), this.get('height'));
},
events: {
'.btn.cancel': {click: 'goBack'},
'.btn.deploy': {click: 'onCharmDeployClicked'},
'.charm-section h4': {click: toggleSectionVisibility},
'.config-file-upload-widget': {change: 'onFileChange'},
'.config-file-upload-overlay': {click: 'onOverlayClick'},
'.config-field': {focus: 'showDescription',
blur: 'hideDescription'},
'input.config-field[type=checkbox]':
{click: function(evt) {evt.target.focus();}},
'#service-name': {blur: 'updateGhostServiceName'}
},
/**
* Determine the Y coordinate that would center a tooltip on a field.
*
* @static
* @param {Number} fieldY The current Y position of the tooltip.
* @param {Number} fieldHeight The hight of the field.
* @param {Number} tooltipHeight The height of the tooltip.
* @return {Number} New Y coordinate for the tooltip.
* @method _calculateTooltipY
*/
_calculateTooltipY: function(fieldY, fieldHeight, tooltipHeight) {
var y_offset = (tooltipHeight - fieldHeight) / 2;
return fieldY - y_offset;
},
/**
* Determine the X coordinate that would place a tooltip next to a
* field.
*
* @static
* @param {Number} fieldX The current X position of the tooltip.
* @param {Number} tooltipWidth The width of the tooltip.
* @return {Number} New X coordinate for the tooltip.
* @method _calculateTooltipX
*/
_calculateTooltipX: function(fieldX, tooltipWidth) {
return fieldX - tooltipWidth - 15;
},
/**
* Move a tooltip to its predefined position.
*
* @method _moveTooltip
*/
_moveTooltip: function() {
if (this.tooltip.field &&
Y.DOM.inRegion(
this.tooltip.field.getDOMNode(),
this.tooltip.panelRegion,
true)) {
var fieldHeight = this.tooltip.field.get('clientHeight');
if (fieldHeight) {
var widget = this.tooltip.get('boundingBox'),
tooltipWidth = widget.get('clientWidth'),
tooltipHeight = widget.get('clientHeight'),
fieldX = this.tooltip.panel.getX(),
fieldY = this.tooltip.field.getY(),
tooltipX = this._calculateTooltipX(
fieldX, tooltipWidth),
tooltipY = this._calculateTooltipY(
fieldY, fieldHeight, tooltipHeight);
this.tooltip.move([tooltipX, tooltipY]);
if (!this.tooltip.get('visible')) {
this.tooltip.show();
}
}
} else if (this.tooltip.get('visible')) {
this.tooltip.hide();
}
},
/**
* Show the charm's description.
*
* @method showDescription
*/
showDescription: function(evt) {
var controlGroup = evt.target.ancestor('.control-group'),
node = controlGroup.one('.control-description'),
text = node.get('text').trim();
this.tooltip.setStdModContent('body', text);
this.tooltip.field = evt.target;
this.tooltip.panel = this.tooltip.field.ancestor(
'.charm-panel');
// Stash for speed.
this.tooltip.panelRegion = Y.DOM.region(
this.tooltip.panel.getDOMNode());
this._moveTooltip();
},
/**
* Hide the charm's description.
*
* @method hideDescription
*/
hideDescription: function(evt) {
this.tooltip.hide();
delete this.tooltip.field;
},
/**
* Pass clicks on the overlay on to the correct recipient.
* The recipient can be the upload widget or the file remove one.
*
* @method onOverlayClick
* @param {Object} evt An event object.
* @return {undefined} Dispatches only.
*/
onOverlayClick: function(evt) {
var container = this.get('container');
if (this.configFileContent) {
this.onFileRemove();
} else {
container.one('.config-file-upload-widget').getDOMNode().click();
}
},
/**
* Handle the file upload click event.
* Call onFileLoaded or onFileError if an error occurs during upload.
*
* @method onFileChange
* @param {Object} evt An event object.
* @return {undefined} Mutates only.
*/
onFileChange: function(evt) {
var container = this.get('container');
console.log('onFileChange:', evt);
this.fileInput = evt.target;
var file = this.fileInput.get('files').shift(),
reader = new FileReader();
container.one('.config-file-name').setContent(file.name);
reader.onerror = Y.bind(this.onFileError, this);
reader.onload = Y.bind(this.onFileLoaded, this);
reader.readAsText(file);
container.one('.config-file-upload-overlay')
.setContent('Remove file');
},
/**
* Handle the file remove click event.
* Restore the file upload widget on click.
*
* @method onFileRemove
* @return {undefined} Mutates only.
*/
onFileRemove: function() {
var container = this.get('container');
this.configFileContent = null;
container.one('.config-file-name').setContent('');
container.one('.charm-settings').show();
// Replace the file input node. There does not appear to be any way
// to reset the element, so the only option is this rather crude
// replacement. It actually works well in practice.
this.fileInput.replace(Y.Node.create('<input type="file"/>')
.addClass('config-file-upload-widget'));
this.fileInput = container.one('.config-file-upload-widget');
var overlay = container.one('.config-file-upload-overlay');
overlay.setContent('Use configuration file');
// Ensure the charm section height is correctly restored.
overlay.ancestor('.collapsible')
.show('sizeIn', {duration: 0.25, width: null});
},
/**
* Callback called when a file is correctly uploaded.
* Hide the charm configuration section.
*
* @method onFileLoaded
* @param {Object} evt An event object.
* @return {undefined} Mutates only.
*/
onFileLoaded: function(evt) {
this.configFileContent = evt.target.result;
if (!this.configFileContent) {
// Some file read errors do not go through the error handler as
// expected but instead return an empty string. Warn the user if
// this happens.
var db = this.get('db');
db.notifications.add(
new models.Notification({
title: 'Configuration file error',
message: 'The configuration file loaded is empty. ' +
'Do you have read access?',
level: 'error'
}));
}
this.get('container').one('.charm-settings').hide();
},
/**
* Callback called when an error occurs during file upload.
* Hide the charm configuration section.
*
* @method onFileError
* @param {Object} evt An event object (with a "target.error" attr).
* @return {undefined} Mutates only.
*/
onFileError: function(evt) {
console.log('onFileError:', evt);
var msg;
switch (evt.target.error.code) {
case evt.target.error.NOT_FOUND_ERR:
msg = 'File not found';
break;
case evt.target.error.NOT_READABLE_ERR:
msg = 'File is not readable';
break;
case evt.target.error.ABORT_ERR:
break; // noop
default:
msg = 'An error occurred reading this file.';
}
if (msg) {
var db = this.get('db');
db.notifications.add(
new models.Notification({
title: 'Error reading configuration file',
message: msg,
level: 'error'
}));
}
return;
},
/**
* Fires an event indicating that the charm panel should switch to the
* "charms" search result view. Called upon clicking the "Cancel"
* button.
*
* @method goBack
* @param {Object} ev An event object (with a "halt" method).
* @return {undefined} Sends a signal only.
*/
goBack: function(ev) {
ev.halt();
this.fire('changePanel', { name: 'charms' });
},
/**
* Clears the ghost service from the database and updates the canvas.
*
* @method _clearGhostService
* @param {Object} ev An event object.
* @return {undefined} Side effects only.
*/
_clearGhostService: function(ev) {
// Remove the ghost service from the environment.
var db = this.get('db');
var ghostService = this.get('ghostService');
if (Y.Lang.isValue(ghostService)) {
db.services.remove(ghostService);
db.fire('update');
}
},
/**
* Updates the ghost service's ID to reflect the service-name field.
*
* @method updateGhostServiceName
* @param {Object} evt The event that's fired.
* @return {undefined} Side effects only.
*/
updateGhostServiceName: function(evt) {
var ghostService = this.get('ghostService');
var container = this.get('container');
var db = this.get('db');
ghostService.set('id', '(' +
container.one('#service-name').get('value') + ')');
db.fire('update');
},
/**
* Called upon clicking the "Confirm" button.
*
* @method onCharmDeployClicked
* @param {Object} ev An event object (with a "halt" method).
* @return {undefined} Sends a signal only.
*/
onCharmDeployClicked: function(evt) {
var container = this.get('container');
var db = this.get('db');
var ghostService = this.get('ghostService');
var env = this.get('env');
var serviceName = container.one('#service-name').get('value');
var numUnits = container.one('#number-units').get('value');
var charm = this.get('model');
var url = charm.get('id');
var config = utils.getElementsValuesMapping(container,
'#service-config .config-field');
var self = this;
var buttons = container.one('.charm-panel-configure-buttons');
// The service names must be unique. It is an error to deploy a
// service with same name (but ignore the ghost service).
var existing_service = db.services.getById(serviceName);
if (Y.Lang.isValue(existing_service) &&
existing_service !== ghostService) {
console.log('Attempting to add service of the same name: ' +
serviceName);
db.notifications.add(
new models.Notification({
title: 'Attempting to deploy service ' + serviceName,
message: 'A service with that name already exists.',
level: 'error'
}));
return;
}
// Disable the buttons to prevent clicking again.
buttons.all('.btn').set('disabled', true);
if (this.configFileContent) {
config = null;
}
numUnits = parseInt(numUnits, 10);
env.deploy(url, serviceName, config, this.configFileContent,
numUnits, function(ev) {
if (ev.err) {
console.log(url + ' deployment failed');
db.notifications.add(
new models.Notification({
title: 'Error deploying ' + serviceName,
message: 'Could not deploy the requested service.',
level: 'error'
}));
} else {
console.log(url + ' deployed');
db.notifications.add(
new models.Notification({
title: 'Deployed ' + serviceName,
message: 'Successfully deployed the requested service.',
level: 'info'
})
);
// Update the annotations with the box's x/y coordinates if
// they have been set by dragging the ghost.
if (ghostService.get('dragged')) {
env.update_annotations(
serviceName, 'service',
{ 'gui-x': ghostService.get('x'),
'gui-y': ghostService.get('y') },
function() { return; });
}
// Update the ghost service to match the configuration.
ghostService.setAttrs({
id: serviceName,
charm: charm.get('id'),
unit_count: 0, // No units yet.
loaded: false,
pending: false,
config: config
});
// Force refresh.
db.fire('update');
self.set('ghostService', null);
}
self.goBack(evt);
});
},
/**
* Setup the panel overlay.
*
* @method setupOverlay
* @param {Object} container The container element.
* @return {undefined} Side effects only.
*/
setupOverlay: function(container) {
var self = this;
container.appendChild(Y.Node.create('<div/>'))
.set('id', 'tooltip');
self.tooltip = new Y.Overlay({ srcNode: '#tooltip',
visible: false});
this.tooltip.render();
}
});
views.CharmConfigurationView = CharmConfigurationView;
/**
* Create the "_instance" object.
*
* @method createInstance
*/
function createInstance(config) {
var charmStore = config.charm_store,
charms = new models.CharmList(),
app = config.app,
container = Y.Node.create('<div />').setAttribute(
'id', 'juju-search-charm-panel'),
charmsSearchPanelNode = Y.Node.create(),
charmsSearchPanel = new CharmCollectionView(
{ container: charmsSearchPanelNode,
env: app.env,
db: app.db,
charms: charms,
charmStore: charmStore }),
descriptionPanelNode = Y.Node.create(),
descriptionPanel = new CharmDescriptionView(
{ container: descriptionPanelNode,
env: app.env,
db: app.db,
charms: charms,
charmStore: charmStore }),
configurationPanelNode = Y.Node.create(),
configurationPanel = new CharmConfigurationView(
{ container: configurationPanelNode,
env: app.env,
db: app.db}),
panels =
{ charms: charmsSearchPanel,
description: descriptionPanel,
configuration: configurationPanel },
// panelHeightOffset takes into account the height of the
// charm filter picker widget, which only appears on the
// "charms" panel.
panelHeightOffset = {
charms: 33,
description: 0,
configuration: 0},
isPanelVisible = false,
trigger = Y.one('#charm-search-trigger'),
searchField = Y.one('#charm-search-field'),
ENTER = Y.Node.DOM_EVENTS.key.eventDef.KEY_MAP.enter,
activePanelName;
Y.one(document.body).append(container);
container.hide();
/**
* Setup the panel data.
*
* @method setPanel
*/
function setPanel(config) {
var newPanel = panels[config.name];
if (!Y.Lang.isValue(newPanel)) {
throw 'Developer error: Unknown panel name ' + config.name;
}
if (activePanelName) {
// Give to the old panel the possibility to clean things up.
panels[activePanelName].fire('panelRemoved');
}
activePanelName = config.name;
container.get('children').remove();
container.append(panels[config.name].get('container'));
newPanel.set('height', calculatePanelPosition().height -
panelHeightOffset[activePanelName] - 1);
if (config.charmId) {
newPanel.set('model', null); // Clear out the old.
var charm = charms.getById(config.charmId);
if (charm.loaded) {
newPanel.set('model', charm);
} else {
charm.load(charmStore, function(err, response) {
if (err) {
console.log('error loading charm', response);
newPanel.fire('changePanel', {name: 'charms'});
} else {
newPanel.set('model', charm);
}
});
}
} else { // This is the search panel.
newPanel.render();
}
}
Y.Object.each(panels, function(panel) {
subscriptions.push(panel.on('changePanel', setPanel));
});
// The panel starts with the "charmsSearchPanel" visible.
setPanel({name: 'charms'});
// Update position if we resize the window.
subscriptions.push(Y.on('windowresize', function(e) {
if (isPanelVisible) {
updatePanelPosition();
}
}));
/**
* Hide the charm panel.
* Set isPanelVisible to false.
*
* @method hide
* @return {undefined} Mutates only.
*/
function hide() {
if (isPanelVisible) {
var headerBox = Y.one('#charm-search-trigger-container'),
headerSpan = headerBox && headerBox.one('span');
if (headerBox) {
headerBox.removeClass('active-border');
if (headerSpan) {
headerSpan.addClass('active-border');
}
}
container.hide();
if (Y.Lang.isValue(trigger)) {
trigger.one('i#charm-search-chevron').replaceClass(
'chevron_up', 'chevron_down');
}
isPanelVisible = false;
}
}
subscriptions.push(container.on('clickoutside', hide));
subscriptions.push(Y.on('beforePageSizeRecalculation', function() {
container.setStyle('display', 'none');
}));
subscriptions.push(Y.on('afterPageSizeRecalculation', function() {
if (isPanelVisible) {
// We need to do this both in windowresize and here because
// windowresize can only be fired with "on," and so we cannot know
// which handler will be fired first.
updatePanelPosition();
}
}));
/**
* Show the charm panel.
* Set isPanelVisible to true.
*
* @method show
* @return {undefined} Mutates only.
*/
function show() {
if (!isPanelVisible) {
var headerBox = Y.one('#charm-search-trigger-container'),
headerSpan = headerBox && headerBox.one('span');
if (headerBox) {
headerBox.addClass('active-border');
if (headerSpan) {
headerSpan.removeClass('active-border');
}
}
container.setStyles({opacity: 0, display: 'block'});
container.show(true);
isPanelVisible = true;
if (app.views.environment.instance) {
app.views.environment.instance.topo.fire('clearState');
}
updatePanelPosition();
if (Y.Lang.isValue(trigger)) {
trigger.one('i#charm-search-chevron').replaceClass(
'chevron_down', 'chevron_up');
}
}
}
/**
* Show the charm panel if it is hidden, hide it otherwise.
*
* @method toggle
* @param {Object} ev An event object (with a "halt" method).
* @return {undefined} Dispatches only.
*/
function toggle(ev) {
if (Y.Lang.isValue(ev)) {
// This is important to not have the clickoutside handler immediately
// undo a "show".
ev.halt();
}
if (isPanelVisible) {
hide();
} else {
show();
}
}
/**
* Update the panel position.
*
* This should only be called when the popup is supposed to be visible.
* We need to hide the popup before we calculate positions, so that it
* does not cause scrollbars to appear while we are calculating
* positions. The temporary scrollbars can cause the calculations to
* be incorrect.
*
* @method updatePanelPosition
*/
function updatePanelPosition() {
container.setStyle('display', 'none');
var pos = calculatePanelPosition();
container.setStyle('display', 'block');
if (pos.height) {
var height = pos.height - panelHeightOffset[activePanelName];
container.setStyle('height', pos.height + 'px');
panels[activePanelName].set('height', height - 1);
}
}
/**
* Calculate the panel position.
*
* @method calculatePanelPosition
*/
function calculatePanelPosition() {
var headerBox = Y.one('#charm-search-trigger-container'),
dimensions = utils.getEffectiveViewportSize();
return { x: headerBox && Math.round(headerBox.getX()),
height: dimensions.height + 18 };
}
if (Y.Lang.isValue(trigger)) {
subscriptions.push(trigger.on('click', toggle));
}
var handleKeyDown = function(ev) {
if (ev.keyCode === ENTER) {
ev.halt(true);
show();
charmsSearchPanel.set('searchText', ev.target.get('value'));
setPanel({name: 'charms'});
}
};
if (searchField) {
subscriptions.push(searchField.on('keydown', handleKeyDown));
}
// The public methods.
return {
hide: hide,
toggle: toggle,
show: show,
node: container,
/**
* Set the default charm series in the search and description panels.
*
* @method setDefaultSeries
*/
setDefaultSeries: function(series) {
charmsSearchPanel.set('defaultSeries', series);
descriptionPanel.set('defaultSeries', series);
}
};
}
// The public methods.
views.CharmPanel = {
/**
* Get the instance, creating it if it does not yet exist.
*
* @method getInstance
*/
getInstance: function(config) {
if (!_instance) {
_instance = createInstance(config);
}
return _instance;
},
/**
* Destroy the instance and its node, detaching all subscriptions.
*
* @method getInstance
*/
killInstance: function() {
while (subscriptions.length) {
var sub = subscriptions.pop();
if (sub) { sub.detach(); }
}
if (_instance) {
_instance.node.remove(true);
_instance = null;
}
}
};
// Exposed for testing.
views.filterEntries = filterEntries;
}, '0.1.0', {
requires: [
'view',
'juju-view-utils',
'juju-templates',
'node',
'handlebars',
'event-hover',
'transition',
'event-key',
'event-outside',
'widget-anim',
'overlay',
'dom-core',
'juju-models',
'event-resize',
'gallery-ellipsis'
]
});