http://git-wip-us.apache.org/repos/asf/brooklyn-ui/blob/18b073a9/src/main/webapp/assets/js/view/application-add-wizard.js ---------------------------------------------------------------------- diff --git a/src/main/webapp/assets/js/view/application-add-wizard.js b/src/main/webapp/assets/js/view/application-add-wizard.js new file mode 100644 index 0000000..2c4f012 --- /dev/null +++ b/src/main/webapp/assets/js/view/application-add-wizard.js @@ -0,0 +1,838 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. +*/ +/** + * Builds a Twitter Bootstrap modal as the framework for a Wizard. + * Also creates an empty Application model. + */ +define([ + "underscore", "jquery", "backbone", "brooklyn-utils", "js-yaml", + "model/entity", "model/application", "model/location", "model/catalog-application", + "text!tpl/app-add-wizard/modal-wizard.html", + "text!tpl/app-add-wizard/create.html", + "text!tpl/app-add-wizard/create-step-template-entry.html", + "text!tpl/app-add-wizard/create-entity-entry.html", + "text!tpl/app-add-wizard/required-config-entry.html", + "text!tpl/app-add-wizard/edit-config-entry.html", + "text!tpl/app-add-wizard/deploy.html", + "text!tpl/app-add-wizard/deploy-version-option.html", + "text!tpl/app-add-wizard/deploy-location-row.html", + "text!tpl/app-add-wizard/deploy-location-option.html", + "bootstrap" + +], function (_, $, Backbone, Util, JsYaml, Entity, Application, Location, CatalogApplication, + ModalHtml, CreateHtml, CreateStepTemplateEntryHtml, CreateEntityEntryHtml, + RequiredConfigEntryHtml, EditConfigEntryHtml, DeployHtml, + DeployVersionOptionHtml, DeployLocationRowHtml, DeployLocationOptionHtml +) { + + /** Special ID to indicate that no locations will be provided when starting the server. */ + var NO_LOCATION_INDICATOR = "__NONE__"; + + function setVisibility(obj, isVisible) { + if (isVisible) obj.show(); + else obj.hide(); + } + + function setEnablement(obj, isEnabled) { + obj.attr("disabled", !isEnabled) + } + + /** converts old-style spec with "entities" to camp-style spec with services */ + function oldSpecToCamp(spec) { + var services; + if (spec.type) { + services = [entityToCamp({type: spec.type, version: spec.version, config: spec.config})]; + } else if (spec.entities) { + services = []; + var entities = spec.entities; + for (var i = 0; i < entities.length; i++) { + services.push(entityToCamp(entities[i])); + } + } + var result = {}; + if (spec.name) result.name = spec.name; + if (spec.locations) { + if (spec.locations.length>1) + result.locations = spec.locations; + else + result.location = spec.locations[0]; + } + if (services) result.services = services; + // NB: currently nothing else is supported in this spec + return result; + } + function entityToCamp(entity) { + var result = {}; + if (entity.name && (!options || !options.exclude_name)) result.name = entity.name; + if (entity.type) result.type = entity.type; + if (entity.type && entity.version) result.type += ":" + entity.version; + if (entity.config && _.size(entity.config)) result["brooklyn.config"] = entity.config; + return result; + } + function getConvertedConfigValue(value) { + try { + return $.parseJSON(value); + } catch (e) { + return value; + } + } + + var ModalWizard = Backbone.View.extend({ + tagName:'div', + className:'modal hide fade', + events:{ + 'click #prev_step':'prevStep', + 'click #next_step':'nextStep', + 'click #preview_step':'previewStep', + 'click #finish_step':'finishStep' + }, + template:_.template(ModalHtml), + initialize:function () { + this.catalog = {} + this.catalog.applications = {} + this.model = {} + this.model.spec = new Application.Spec; + this.model.yaml = ""; + this.model.mode = "template"; // or "yaml" or "other" + this.currentStep = 0; + this.steps = [ + { + step_id:'what-app', + title:'Create Application', + instructions:'Choose or build the application to deploy', + view:new ModalWizard.StepCreate({ model:this.model, wizard: this, catalog: this.catalog }) + }, + { + // TODO rather than make this another step -- since we now on preview revert to the yaml tab + // this should probably be shown in the catalog tab, replacing the other contents. + step_id:'name-and-locations', + title:'<%= appName %>', + instructions:'Specify the locations to deploy to and any additional configuration', + view:new ModalWizard.StepDeploy({ model:this.model, catalog: this.catalog }) + } + ] + }, + beforeClose:function () { + // ensure we close the sub-views + _.each(this.steps, function (step) { + step.view.close() + }, this) + }, + render:function () { + this.$el.html(this.template({})) + this.renderCurrentStep() + return this + }, + + renderCurrentStep:function (callback) { + var name = this.model.name || ""; + this.title = this.$("h3#step_title") + this.instructions = this.$("p#step_instructions") + + var currentStepObj = this.steps[this.currentStep] + this.title.html(_.template(currentStepObj.title)({appName: name})); + this.instructions.html(currentStepObj.instructions) + this.currentView = currentStepObj.view + + // delegate to sub-views !! + this.currentView.render() + this.currentView.updateForState() + this.$(".modal-body").replaceWith(this.currentView.el) + if (callback) callback(this.currentView); + + this.updateButtonVisibility(); + }, + updateButtonVisibility:function () { + var currentStepObj = this.steps[this.currentStep] + + setVisibility(this.$("#prev_step"), (this.currentStep > 0)) + + // next shown for first step, but not for yaml + var nextVisible = (this.currentStep < 1) && (this.model.mode != "yaml") + setVisibility(this.$("#next_step"), nextVisible) + + // previous shown for step 2 (but again, not yaml) + var previewVisible = (this.currentStep == 1) && (this.model.mode != "yaml") + setVisibility(this.$("#preview_step"), previewVisible) + + // now set next/preview enablement + if (nextVisible || previewVisible) { + var nextEnabled = true; + if (this.currentStep==0 && this.model.mode=="template" && currentStepObj && currentStepObj.view) { + // disable if this is template selction (lozenge) view, and nothing is selected + if (! currentStepObj.view.selectedTemplate) + nextEnabled = false; + } + + if (nextVisible) + setEnablement(this.$("#next_step"), nextEnabled) + if (previewVisible) + setEnablement(this.$("#preview_step"), nextEnabled) + } + + // finish from config step, preview step, and from first step if yaml tab selected (and valid) + var finishVisible = (this.currentStep >= 1) + var finishEnabled = finishVisible + if (!finishEnabled && this.currentStep==0) { + if (this.model.mode == "yaml") { + // should do better validation than non-empty + finishVisible = true; + var yaml_code = this.$("#yaml_code").val() + if (yaml_code) { + finishEnabled = true; + } + } + } + setVisibility(this.$("#finish_step"), finishVisible) + setEnablement(this.$("#finish_step"), finishEnabled) + }, + + submitApplication:function (event) { + var that = this + var $modal = $('.add-app #modal-container .modal') + $modal.fadeTo(500,0.5); + + var yaml; + if (this.model.mode == "yaml") { + yaml = this.model.yaml; + } else { + // Drop any "None" locations. + this.model.spec.pruneLocations(); + yaml = JsYaml.safeDump(oldSpecToCamp(this.model.spec.toJSON())); + } + + $.ajax({ + url:'/v1/applications', + type:'post', + contentType:'application/yaml', + processData:false, + data:yaml, + success:function (data) { + that.onSubmissionComplete(true, data, $modal) + }, + error:function (data) { + that.onSubmissionComplete(false, data, $modal) + } + }); + + return false + }, + onSubmissionComplete: function(succeeded, data, $modal) { + var that = this; + if (succeeded) { + $modal.modal('hide') + $modal.fadeTo(500,1); + if (that.options.callback) that.options.callback(); + } else { + log("ERROR submitting application: "+data.responseText); + var response, summary="Server responded with an error"; + try { + if (data.responseText) { + response = JSON.parse(data.responseText) + if (response) { + summary = response.message; + } + } + } catch (e) { + summary = data.responseText; + } + that.$el.fadeTo(100,1).delay(200).fadeTo(200,0.2).delay(200).fadeTo(200,1); + that.steps[that.currentStep].view.showFailure(summary) + } + }, + + prevStep:function () { + this.currentStep -= 1; + this.renderCurrentStep(); + }, + nextStep:function () { + if (this.currentStep == 0) { + if (this.currentView.validate()) { + var yaml = (this.currentView && this.currentView.selectedTemplate && this.currentView.selectedTemplate.yaml); + if (yaml) { + try { + yaml = JsYaml.safeLoad(yaml); + hasLocation = yaml.location || yaml.locations; + if (!hasLocation) { + // look for locations defined in locations + svcs = yaml.services; + if (svcs) { + for (svcI in svcs) { + if (svcs[svcI].location || svcs[svcI].locations) { + hasLocation = true; + break; + } + } + } + } + yaml = (hasLocation ? true : false); + } catch (e) { + log("Warning: could not parse yaml template") + log(yaml); + yaml = false; + } + } + if (yaml) { + // it's a yaml catalog template which includes a location, show the yaml tab + $("ul#app-add-wizard-create-tab").find("a[href='#yamlTab']").tab('show'); + $("#yaml_code").setCaretToStart(); + } else { + // it's a java catalog template or yaml template without a location, go to wizard + this.currentStep += 1; + this.renderCurrentStep(); + } + } else { + // the call to validate will have done the showFailure + } + } else { + throw "Unexpected step: "+this.currentStep; + } + }, + previewStep:function () { + if (this.currentView.validate()) { + this.currentStep = 0; + var that = this; + this.renderCurrentStep(function callback(view) { + // Drop any "None" locations. + that.model.spec.pruneLocations(); + $("textarea#yaml_code").val(JsYaml.safeDump(oldSpecToCamp(that.model.spec.toJSON()))); + $("ul#app-add-wizard-create-tab").find("a[href='#yamlTab']").tab('show'); + $("#yaml_code").setCaretToStart(); + }); + } else { + // call to validate should showFailure + } + }, + finishStep:function () { + if (this.currentView.validate()) { + this.submitApplication() + } else { + // call to validate should showFailure + } + } + }) + + // Note: this does not restore values on a back click; setting type and entity type+name is easy, + // but relevant config lines is a little bit more tedious + ModalWizard.StepCreate = Backbone.View.extend({ + className:'modal-body', + events:{ + 'click #add-app-entity':'addEntityBox', + 'click .editable-entity-heading':'expandEntity', + 'click .remove-entity-button':'removeEntityClick', + 'click .editable-entity-button':'saveEntityClick', + 'click #remove-config':'removeConfigRow', + 'click #add-config':'addConfigRow', + 'click .template-lozenge':'templateClick', + 'keyup .text-filter input':'applyFilter', + 'change .text-filter input':'applyFilter', + 'paste .text-filter input':'applyFilter', + 'keyup #yaml_code':'onYamlCodeChange', + 'change #yaml_code':'onYamlCodeChange', + 'paste #yaml_code':'onYamlCodeChange', + 'shown a[data-toggle="tab"]':'onTabChange', + 'click #templateTab #catalog-add':'switchToCatalogAdd', + 'click #templateTab #catalog-yaml':'showYamlTab' + }, + template:_.template(CreateHtml), + wizard: null, + initialize:function () { + var self = this + self.catalogEntityIds = [] + + this.$el.html(this.template({})) + + // for building from entities + this.addEntityBox() + + // TODO: Make into models, allow options to override, then pass in in test + // with overrridden url. Can then think about fixing tests in application-add-wizard-spec.js. + $.get('/v1/catalog/entities', {}, function (result) { + self.catalogEntityItems = result + self.catalogEntityIds = _.map(result, function(item) { return item.id }) + self.$(".entity-type-input").typeahead().data('typeahead').source = self.catalogEntityIds + }) + this.options.catalog.applications = new CatalogApplication.Collection(); + this.options.catalog.applications.fetch({ + data: $.param({ + allVersions: true + }), + success: function (collection, response, options) { + self.$("#appClassTab .application-type-input").typeahead().data('typeahead').source = collection.getTypes(); + $('#catalog-applications-throbber').hide(); + $('#catalog-applications-empty').hide(); + if (collection.size() > 0) { + self.addTemplateLozenges() + } else { + $('#catalog-applications-empty').show(); + self.showYamlTab(); + } + } + }); + }, + renderConfiguredEntities:function () { + var $configuredEntities = this.$('#entitiesAccordionish').empty() + var that = this + if (this.model.spec.get("entities") && this.model.spec.get("entities").length > 0) { + _.each(this.model.spec.get("entities"), function (entity) { + that.addEntityHtml($configuredEntities, entity) + }) + } + }, + updateForState: function () {}, + render:function () { + this.renderConfiguredEntities() + this.delegateEvents() + return this + }, + onTabChange: function(e) { + var tabText = $(e.target).text(); + if (tabText=="Catalog") { + $("li.text-filter").show() + } else { + $("li.text-filter").hide() + } + + if (tabText=="YAML") { + this.model.mode = "yaml"; + } else if (tabText=="Template") { + this.model.mode = "template"; + } else { + this.model.mode = "other"; + } + + if (this.options.wizard) + this.options.wizard.updateButtonVisibility(); + }, + onYamlCodeChange: function() { + if (this.options.wizard) + this.options.wizard.updateButtonVisibility(); + }, + switchToCatalogAdd: function() { + var $modal = $('.add-app #modal-container .modal') + $modal.modal('hide'); + window.location.href="#v1/catalog/new"; + }, + showYamlTab: function() { + $("ul#app-add-wizard-create-tab").find("a[href='#yamlTab']").tab('show') + $("#yaml_code").focus(); + }, + applyFilter: function(e) { + var filter = $(e.currentTarget).val().toLowerCase() + if (!filter) { + $(".template-lozenge").show() + } else { + _.each($(".template-lozenge"), function(it) { + var viz = $(it).text().toLowerCase().indexOf(filter)>=0 + if (viz) + $(it).show() + else + $(it).hide() + }) + } + }, + addTemplateLozenges: function(event) { + var that = this + _.each(this.options.catalog.applications.getDistinctApplications(), function(item) { + that.addTemplateLozenge(that, item[0]) + }) + }, + addTemplateLozenge: function(that, item) { + var $tempel = _.template(CreateStepTemplateEntryHtml, { + id: item.get('id'), + type: item.get('type'), + name: item.get('name') || item.get('id'), + description: item.get('description'), + planYaml: item.get('planYaml'), + iconUrl: item.get('iconUrl') + }) + $("#create-step-template-entries", that.$el).append($tempel) + }, + templateClick: function(event) { + var $tl = $(event.target).closest(".template-lozenge"); + var wasSelected = $tl.hasClass("selected") + $(".template-lozenge").removeClass("selected") + if (!wasSelected) { + $tl.addClass("selected") + this.selectedTemplate = { + id: $tl.attr('id'), + type: $tl.data('type'), + name: $tl.data("name"), + yaml: $tl.data("yaml"), + }; + if (this.selectedTemplate.yaml) { + $("textarea#yaml_code").val(this.selectedTemplate.yaml); + } else { + $("textarea#yaml_code").val("services:\n- type: "+this.selectedTemplate.type); + } + } else { + this.selectedTemplate = null; + } + + if (this.options.wizard) + this.options.wizard.updateButtonVisibility(); + }, + expandEntity:function (event) { + $(event.currentTarget).next().show('fast').delay(1000).prev().hide('slow') + }, + saveEntityClick:function (event) { + this.saveEntity($(event.currentTarget).closest(".editable-entity-group")); + }, + saveEntity:function ($entityGroup) { + var that = this + var name = $('#entity-name',$entityGroup).val() + var type = $('#entity-type',$entityGroup).val() + if (type=="" || !_.contains(that.catalogEntityIds, type)) { + that.showFailure("Missing or invalid type"); + return false + } + var saveTarget = this.model.spec.get("entities")[$entityGroup.index()]; + this.model.spec.set("type", null) + saveTarget.name = name + saveTarget.type = type + saveTarget.config = this.getConfigMap($entityGroup) + + if (name=="") name=type; + if (name=="") name="<i>(new entity)</i>"; + $('#entity-name-header',$entityGroup).html( name ) + $('.editable-entity-body',$entityGroup).prev().show('fast').next().hide('fast') + return true; + }, + getConfigMap:function (root) { + var map = {} + $('.app-add-wizard-config-entry',root).each( function (index,elt) { + var value = getConvertedConfigValue($('#value',elt).val()); + if (value !== null) { + map[$('#key',elt).val()] = value; + } + }) + return map; + }, + saveTemplate:function () { + if (!this.selectedTemplate) return false + var type = this.selectedTemplate.type; + if (!this.options.catalog.applications.hasType(type)) { + $('.entity-info-message').show('slow').delay(2000).hide('slow') + return false + } + + this.model.spec.set("type", type); + this.model.name = this.selectedTemplate.name; + this.model.catalogEntityData = "LOAD" + return true; + }, + saveAppClass:function () { + var that = this + var tab = $.find('#appClassTab') + var type = $(tab).find('#app-java-type').val() + if (!this.options.catalog.applications.hasType(type)) { + $('.entity-info-message').show('slow').delay(2000).hide('slow') + return false + } + this.model.spec.set("type", type); + return true; + }, + addEntityBox:function () { + var entity = new Entity.Model + this.model.spec.addEntity( entity ) + this.addEntityHtml($('#entitiesAccordionish', this.$el), entity) + }, + addEntityHtml:function (parent, entity) { + var $entity = _.template(CreateEntityEntryHtml, {}) + var that = this + parent.append($entity) + parent.children().last().find('.entity-type-input').typeahead({ source: that.catalogEntityIds }) + }, + removeEntityClick:function (event) { + var $entityGroup = $(event.currentTarget).parent().parent().parent(); + this.model.spec.removeEntityIndex($entityGroup.index()) + $entityGroup.remove() + }, + + addConfigRow:function (event) { + var $row = _.template(EditConfigEntryHtml, {}) + $(event.currentTarget).parent().prev().append($row) + }, + removeConfigRow:function (event) { + $(event.currentTarget).parent().remove() + }, + + validate:function () { + var that = this + var tabName = $('#app-add-wizard-create-tab li[class="active"] a').attr('href') + if (tabName=='#entitiesTab') { + delete this.model.spec.attributes["id"] + var allokay = true + $($.find('.editable-entity-group')).each( + function (i,$entityGroup) { + allokay = that.saveEntity($($entityGroup)) & allokay + }) + if (!allokay) return false; + if (this.model.spec.get("entities") && this.model.spec.get("entities").length > 0) { + this.model.spec.set("type", null); + return true; + } + } else if (tabName=='#templateTab') { + delete this.model.spec.attributes["id"] + if (this.saveTemplate()) { + this.model.spec.set("entities", []); + return true + } + } else if (tabName=='#appClassTab') { + delete this.model.spec.attributes["id"] + if (this.saveAppClass()) { + this.model.spec.set("entities", []); + return true + } + } else if (tabName=='#yamlTab') { + this.model.yaml = this.$("#yaml_code").val(); + if (this.model.yaml) { + return true; + } + } else { + console.info("NOT IMPLEMENTED YET") + // TODO - other tabs not implemented yet + // do nothing, show error return false below + } + this.showFailure("Invalid application type/spec"); + return false + }, + + showFailure: function(text) { + if (!text) text = "Failure performing the specified action"; + this.$('div.error-message .error-message-text').html(_.escape(text)); + this.$('div.error-message').slideDown(250).delay(10000).slideUp(500); + } + + }) + + ModalWizard.StepDeploy = Backbone.View.extend({ + className:'modal-body', + + events:{ + 'click #add-selector-container':'addLocation', + 'click #remove-app-location':'removeLocation', + 'change .select-version': 'selectionVersion', + 'change .select-location': 'selectionLocation', + 'blur #application-name':'updateName', + 'click #remove-config':'removeConfigRow', + 'click #add-config':'addConfigRow' + }, + + template:_.template(DeployHtml), + versionOptionTemplate:_.template(DeployVersionOptionHtml), + locationRowTemplate:_.template(DeployLocationRowHtml), + locationOptionTemplate:_.template(DeployLocationOptionHtml), + + initialize:function () { + this.model.spec.on("change", this.render, this) + this.$el.html(this.template()) + this.locations = new Location.Collection() + }, + beforeClose:function () { + this.model.spec.off("change", this.render) + }, + renderName:function () { + this.$('#application-name').val(this.model.spec.get("name")) + }, + renderVersions: function() { + var optionTemplate = this.versionOptionTemplate + select = this.$('.select-version') + container = this.$('#app-versions') + defaultVersion = '0.0.0.SNAPSHOT'; + + select.empty(); + + var versions = this.options.catalog.applications.getVersions(this.model.spec.get('type')); + for (var vi = 0; vi < versions.length; vi++) { + var version = versions[vi]; + select.append(optionTemplate({ + version: version + })); + } + + if (versions.length === 1 && versions[0] === defaultVersion) { + this.model.spec.set('version', ''); + container.hide(); + } else { + this.model.spec.set('version', versions[0]); + container.show(); + } + }, + renderAddedLocations:function () { + // renders the locations added to the model + var rowTemplate = this.locationRowTemplate, + optionTemplate = this.locationOptionTemplate, + container = this.$("#selector-container-location"); + container.empty(); + for (var li = 0; li < this.model.spec.get("locations").length; li++) { + var chosenLocation = this.model.spec.get("locations")[li]; + container.append(rowTemplate({ + initialValue: chosenLocation, + rowId: li + })); + } + var $locationOptions = container.find('.select-location'); + var templated = this.locations.map(function(aLocation) { + return optionTemplate({ + id: aLocation.id || "", + name: aLocation.getPrettyName() + }); + }); + + // insert "none" location + $locationOptions.append(templated.join("")); + $locationOptions.each(function(i) { + var option = $($locationOptions[i]); + option.val(option.parent().attr('initialValue')); + // Only append dashes if there are any locations + if (option.find("option").length > 0) { + option.append("<option disabled>------</option>"); + } + option.append(optionTemplate({ + id: NO_LOCATION_INDICATOR, + name: "None" + })); + }); + }, + render:function () { + this.delegateEvents() + return this + }, + updateForState: function () { + var that = this + // clear any error message (we are being displayed fresh; if there are errors in the update, we'll show them in code below) + this.$('div.error-message').hide(); + this.renderName() + this.renderVersions() + this.locations.fetch({ + success:function () { + if (that.model.spec.get("locations").length==0) + that.addLocation() + else + that.renderAddedLocations() + }}) + + if (this.model.catalogEntityData==null) { + this.renderStaticConfig(null) + } else if (this.model.catalogEntityData=="LOAD") { + this.renderStaticConfig("LOADING") + $.get('/v1/catalog/entities/'+this.model.spec.get("type"), {}, function (result) { + that.model.catalogEntityData = result + that.renderStaticConfig(that.model.catalogEntityData) + }) + } else { + this.renderStaticConfig(this.model.catalogEntityData) + } + }, + addLocation:function () { + if (this.locations.models.length>0) { + this.model.spec.addLocation(this.locations.models[0].get("id")) + } else { + // i.e. No location + this.model.spec.addLocation(undefined); + } + this.renderAddedLocations() + }, + removeLocation:function (event) { + var toBeRemoved = $(event.currentTarget).parent().attr('rowId') + this.model.spec.removeLocationIndex(toBeRemoved) + this.renderAddedLocations() + }, + addConfigRow:function (event) { + var $row = _.template(EditConfigEntryHtml, {}) + $(event.currentTarget).parent().prev().append($row) + }, + removeConfigRow:function (event) { + $(event.currentTarget).parent().parent().remove() + }, + renderStaticConfig:function (catalogEntryItem) { + this.$('.config-table').html('') + if (catalogEntryItem=="LOADING") { + this.$('.required-config-loading').show() + } else { + var configs = [] + this.$('.required-config-loading').hide() + if (catalogEntryItem!=null && catalogEntryItem.config!=null) { + var that = this + _.each(catalogEntryItem.config, function (cfg) { + if (cfg.priority !== undefined) { + var html = _.template(RequiredConfigEntryHtml, {data:cfg}); + that.$('.config-table').append(html) + } + }) + } + } + }, + getConfigMap:function() { + var map = {}; + $('.app-add-wizard-config-entry').each( function (index,elt) { + var value = $('#checkboxValue',elt).length ? $('#checkboxValue',elt).is(':checked') : + getConvertedConfigValue($('#value',elt).val()); + if (value !== null) { + map[$('#key',elt).val()] = value; + } + }) + return map; + }, + selectionVersion:function (event) { + this.model.spec.set("version", $(event.currentTarget).val()) + }, + selectionLocation:function (event) { + var loc_id = $(event.currentTarget).val(), + isNoneLocation = loc_id === NO_LOCATION_INDICATOR; + var locationValid = isNoneLocation || this.locations.find(function (candidate) { + return candidate.get("id")==loc_id; + }); + if (!locationValid) { + log("invalid location "+loc_id); + this.showFailure("Invalid location "+loc_id); + this.model.spec.set("locations",[]); + } else { + var index = $(event.currentTarget).parent().attr('rowId'); + this.model.spec.setLocationAtIndex(index, isNoneLocation ? undefined : loc_id); + } + }, + updateName:function () { + var name = this.$('#application-name').val(); + if (name) + this.model.spec.set("name", name); + else + this.model.spec.set("name", ""); + }, + validate:function () { + this.model.spec.set("config", this.getConfigMap()) + if (this.model.spec.get("locations").length !== 0) { + return true + } else { + this.showFailure("A location is required"); + return false; + } + }, + showFailure: function(text) { + if (!text) text = "Failure performing the specified action"; + log("showing error: "+text); + this.$('div.error-message .error-message-text').html(_.escape(text)); + // flash the error, but make sure it goes away (we do not currently have any other logic for hiding this error message) + this.$('div.error-message').slideDown(250).delay(10000).slideUp(500); + } + }) + + return ModalWizard +})
http://git-wip-us.apache.org/repos/asf/brooklyn-ui/blob/18b073a9/src/main/webapp/assets/js/view/application-explorer.js ---------------------------------------------------------------------- diff --git a/src/main/webapp/assets/js/view/application-explorer.js b/src/main/webapp/assets/js/view/application-explorer.js new file mode 100644 index 0000000..e9c23bc --- /dev/null +++ b/src/main/webapp/assets/js/view/application-explorer.js @@ -0,0 +1,205 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. +*/ +/** + * This should render the main content in the Application Explorer page. + * Components on this page should be rendered as sub-views. + * @type {*} + */ +define([ + "underscore", "jquery", "backbone", "view/viewutils", + "./application-add-wizard", "model/application", "model/entity-summary", "model/app-tree", "./application-tree", "./entity-details", + "text!tpl/apps/details.html", "text!tpl/apps/entity-not-found.html", "text!tpl/apps/page.html" +], function (_, $, Backbone, ViewUtils, + AppAddWizard, Application, EntitySummary, AppTree, ApplicationTreeView, EntityDetailsView, + EntityDetailsEmptyHtml, EntityNotFoundHtml, PageHtml) { + + var ApplicationExplorerView = Backbone.View.extend({ + tagName:"div", + className:"container container-fluid", + id:'application-explorer', + template:_.template(PageHtml), + notFoundTemplate: _.template(EntityNotFoundHtml), + events:{ + 'click .application-tree-refresh': 'refreshApplicationsInPlace', + 'click #add-new-application':'createApplication', + 'click .delete':'deleteApplication' + }, + initialize: function () { + this.$el.html(this.template({})) + $(".nav1").removeClass("active"); + $(".nav1_apps").addClass("active"); + + this.treeView = new ApplicationTreeView({ + collection:this.collection, + appRouter:this.options.appRouter + }) + this.treeView.on('entitySelected', function(e) { + this.displayEntityId(e.id, e.get('applicationId'), false); + }, this); + this.$('div#app-tree').html(this.treeView.renderFull().el) + this.$('div#details').html(EntityDetailsEmptyHtml); + + ViewUtils.fetchRepeatedlyWithDelay(this, this.collection) + }, + refreshApplicationsInPlace: function() { + // fetch without reset sets of change events, which now get handled correctly + // (not a full visual recompute, which reset does - both in application-tree.js) + this.collection.fetch(); + }, + beforeClose: function () { + this.collection.off("reset", this.render); + this.treeView.close(); + if (this.detailsView) + this.detailsView.close(); + }, + show: function(entityId) { + var tab = ""; + var tabDetails = ""; + if (entityId) { + if (entityId[0]=='/') entityId = entityId.substring(1); + var slash = entityId.indexOf('/'); + if (slash>0) { + tab = entityId.substring(slash+1) + entityId = entityId.substring(0, slash); + } + } + if (tab) { + var slash = tab.indexOf('/'); + if (slash>0) { + tabDetails = tab.substring(slash+1) + tab = tab.substring(0, slash); + } + this.preselectTab(tab, tabDetails); + } + this.treeView.selectEntity(entityId) + }, + createApplication:function () { + var that = this; + if (this._modal) { + this._modal.close() + } + var wizard = new AppAddWizard({ + appRouter:that.options.appRouter, + callback:function() { that.refreshApplicationsInPlace() } + }) + this._modal = wizard + this.$(".add-app #modal-container").html(wizard.render().el) + this.$(".add-app #modal-container .modal") + .on("hidden",function () { + wizard.close() + }).modal('show') + }, + deleteApplication:function (event) { + // call Backbone destroy() which does HTTP DELETE on the model + this.collection.get(event.currentTarget['id']).destroy({wait:true}) + }, + /** + * Causes the tab with the given name to be selected automatically when + * the view is next rendered. + */ + preselectTab: function(tab, tabDetails) { + this.currentTab = tab; + this.currentTabDetails = tabDetails; + }, + showDetails: function(app, entitySummary) { + var that = this; + ViewUtils.cancelFadeOnceLoaded($("div#details")); + + var whichTab = this.currentTab; + if (!whichTab) { + whichTab = "summary"; + if (this.detailsView) { + whichTab = this.detailsView.$el.find(".tab-pane.active").attr("id"); + this.detailsView.close(); + } + } + if (this.detailsView) { + this.detailsView.close(); + } + this.detailsView = new EntityDetailsView({ + model: entitySummary, + application: app, + appRouter: this.options.appRouter, + preselectTab: whichTab, + preselectTabDetails: this.currentTabDetails, + }); + + this.detailsView.on("entity.expunged", function() { + that.preselectTab("summary"); + var id = that.selectedEntityId; + var model = that.collection.get(id); + if (model && model.get("parentId")) { + that.displayEntityId(model.get("parentId")); + } else if (that.collection) { + that.displayEntityId(that.collection.first().id); + } else if (id) { + that.displayEntityNotFound(id); + } else { + that.displayEntityNotFound("?"); + } + that.collection.fetch(); + }); + this.detailsView.render( $("div#details") ); + }, + displayEntityId: function (id, appName, afterLoad) { + var that = this; + var entityLoadFailed = function() { + return that.displayEntityNotFound(id); + }; + if (appName === undefined) { + if (!afterLoad) { + // try a reload if given an ID we don't recognise + this.collection.includeEntities([id]); + this.collection.fetch({ + success: function() { _.defer(function() { that.displayEntityId(id, appName, true); }); }, + error: function() { _.defer(function() { that.displayEntityId(id, appName, true); }); } + }); + ViewUtils.fadeToIndicateInitialLoad($("div#details")) + return; + } else { + // no such app + entityLoadFailed(); + return; + } + } + + var app = new Application.Model(); + var entitySummary = new EntitySummary.Model; + + app.url = "/v1/applications/" + appName; + entitySummary.url = "/v1/applications/" + appName + "/entities/" + id; + + // in case the server response time is low, fade out while it refreshes + // (since we can't show updated details until we've retrieved app + entity details) + ViewUtils.fadeToIndicateInitialLoad($("div#details")); + + $.when(app.fetch(), entitySummary.fetch()) + .done(function() { + that.showDetails(app, entitySummary); + }) + .fail(entityLoadFailed); + }, + displayEntityNotFound: function(id) { + $("div#details").html(this.notFoundTemplate({"id": id})); + ViewUtils.cancelFadeOnceLoaded($("div#details")) + }, + }) + + return ApplicationExplorerView +}) http://git-wip-us.apache.org/repos/asf/brooklyn-ui/blob/18b073a9/src/main/webapp/assets/js/view/application-tree.js ---------------------------------------------------------------------- diff --git a/src/main/webapp/assets/js/view/application-tree.js b/src/main/webapp/assets/js/view/application-tree.js new file mode 100644 index 0000000..2f7a3d0 --- /dev/null +++ b/src/main/webapp/assets/js/view/application-tree.js @@ -0,0 +1,367 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. +*/ +/** + * Sub-View to render the Application tree. + * @type {*} + */ +define([ + "underscore", "jquery", "backbone", "view/viewutils", + "model/app-tree", "text!tpl/apps/tree-item.html", "text!tpl/apps/tree-empty.html" +], function (_, $, Backbone, ViewUtils, + AppTree, TreeItemHtml, EmptyTreeHtml) { + + var emptyTreeTemplate = _.template(EmptyTreeHtml); + var treeItemTemplate = _.template(TreeItemHtml); + + var findAllTreeboxes = function(id, $scope) { + return $('.tree-box[data-entity-id="' + id + '"]', $scope); + }; + + var findRootTreebox = function(id) { + return $('.lozenge-app-tree-wrapper').children('.tree-box[data-entity-id="' + id + '"]', this.$el); + }; + + var findChildTreebox = function(id, $parentTreebox) { + return $parentTreebox.children('.node-children').children('.tree-box[data-entity-id="' + id + '"]'); + }; + + var findMasterTreebox = function(id, $scope) { + return $('.tree-box[data-entity-id="' + id + '"]:not(.indirect)', $scope); + }; + + var createEntityTreebox = function(id, name, $domParent, depth, indirect) { + // Tildes in sort key force entities with no name to bottom of list (z < ~). + var sortKey = (name ? name.toLowerCase() : "~~~") + " " + id.toLowerCase(); + + // Create the wrapper. + var $treebox = $( + '<div data-entity-id="'+id+'" data-sort-key="'+sortKey+'" data-depth="'+depth+'" ' + + 'class="tree-box toggler-group' + + (indirect ? " indirect" : "") + + (depth == 0 ? " outer" : " inner " + (depth % 2 ? " depth-odd" : " depth-even")+ + (depth == 1 ? " depth-first" : "")) + '">'+ + '<div class="entity_tree_node_wrapper"></div>'+ + '<div class="node-children toggler-target hide"></div>'+ + '</div>'); + + // Insert into the passed DOM parent, maintaining sort order relative to siblings: name then id. + var placed = false; + var contender = $(".toggler-group", $domParent).first(); + while (contender.length && !placed) { + var contenderKey = contender.data("sort-key"); + if (sortKey < contenderKey) { + contender.before($treebox); + placed = true; + } else { + contender = contender.next(".toggler-group", $domParent); + } + } + if (!placed) { + $domParent.append($treebox); + } + return $treebox; + }; + + var getOrCreateApplicationTreebox = function(id, name, treeView) { + var $treebox = findRootTreebox(id); + if (!$treebox.length) { + var $insertionPoint = $('.lozenge-app-tree-wrapper', treeView.$el); + if (!$insertionPoint.length) { + // entire view must be created + treeView.$el.html( + '<div class="navbar_main_wrapper treeloz">'+ + '<div id="tree-list" class="navbar_main treeloz">'+ + '<div class="lozenge-app-tree-wrapper">'+ + '</div></div></div>'); + $insertionPoint = $('.lozenge-app-tree-wrapper', treeView.$el); + } + $treebox = createEntityTreebox(id, name, $insertionPoint, 0, false); + } + return $treebox; + }; + + var getOrCreateChildTreebox = function(id, name, isIndirect, $parentTreebox) { + var $treebox = findChildTreebox(id, $parentTreebox); + if (!$treebox.length) { + $treebox = createEntityTreebox(id, name, $parentTreebox.children('.node-children'), $parentTreebox.data("depth") + 1, isIndirect); + } + return $treebox; + }; + + var updateTreeboxContent = function(entity, $treebox, treeView) { + var $newContent = $(treeView.template({ + id: entity.get('id'), + parentId: entity.get('parentId'), + model: entity, + statusIconUrl: ViewUtils.computeStatusIconInfo(entity.get("serviceUp"), entity.get("serviceState")).url, + indirect: $treebox.hasClass('indirect'), + })); + + var $wrapper = $treebox.children('.entity_tree_node_wrapper'); + + // Preserve old display status (just chevron direction at present). + if ($wrapper.find('.tree-node-state').hasClass('icon-chevron-down')) { + $newContent.find('.tree-node-state').removeClass('icon-chevron-right').addClass('icon-chevron-down'); + } + + $wrapper.html($newContent); + addEventsToNode($treebox, treeView); + }; + + var addEventsToNode = function($node, treeView) { + // show the "light-popup" (expand / expand all / etc) menu + // if user hovers for 500ms. surprising there is no option for this (hover delay). + // also, annoyingly, clicks around the time the animation starts don't seem to get handled + // if the click is in an overlapping reason; this is why we position relative top: 12px in css + $('.light-popup', $node).parent().parent().hover( + function(parent) { + treeView.cancelHoverTimer(); + treeView.hoverTimer = setTimeout(function() { + var menu = $(parent.currentTarget).find('.light-popup'); + menu.show(); + }, 500); + }, + function(parent) { + treeView.cancelHoverTimer(); + $('.light-popup').hide(); + } + ); + }; + + var selectTreebox = function(id, $treebox, treeView) { + $('.entity_tree_node_wrapper').removeClass('active'); + $treebox.children('.entity_tree_node_wrapper').addClass('active'); + + var entity = treeView.collection.get(id); + if (entity) { + treeView.selectedEntityId = id; + treeView.trigger('entitySelected', entity); + } + }; + + + return Backbone.View.extend({ + template: treeItemTemplate, + hoverTimer: null, + + events: { + 'click span.entity_tree_node .tree-change': 'treeChange', + 'click span.entity_tree_node': 'nodeClicked' + }, + + initialize: function() { + this.collection.on('add', this.entityAdded, this); + this.collection.on('change', this.entityChanged, this); + this.collection.on('remove', this.entityRemoved, this); + this.collection.on('reset', this.renderFull, this); + _.bindAll(this); + }, + + beforeClose: function() { + this.collection.off("reset", this.renderFull); + }, + + entityAdded: function(entity) { + // Called when the full entity model is fetched into our collection, at which time we can replace + // the empty contents of any placeholder tree nodes (.tree-box) that were created earlier. + // The entity may have multiple 'treebox' views (in the case of group members). + + // If the new entity is an application, we must create its placeholder in the DOM. + if (!entity.get('parentId')) { + var $treebox = getOrCreateApplicationTreebox(entity.id, entity.get('name'), this); + + // Select the new app if there's no current selection. + if (!this.selectedEntityId) + selectTreebox(entity.id, $treebox, this); + } + + this.entityChanged(entity); + }, + + entityChanged: function(entity) { + // The entity may have multiple 'treebox' views (in the case of group members). + var that = this; + findAllTreeboxes(entity.id).each(function() { + var $treebox = $(this); + updateTreeboxContent(entity, $treebox, that); + }); + }, + + entityRemoved: function(entity) { + // The entity may have multiple 'treebox' views (in the case of group members). + findAllTreeboxes(entity.id, this.$el).remove(); + // Collection seems sometimes to retain children of the removed node; + // not sure why, but that's okay for now. + if (this.collection.getApplications().length == 0) + this.renderFull(); + }, + + nodeClicked: function(event) { + var $treebox = $(event.currentTarget).closest('.tree-box'); + var id = $treebox.data('entityId'); + selectTreebox(id, $treebox, this); + return false; + }, + + selectEntity: function(id) { + var $treebox = findMasterTreebox(id, this.$el); + selectTreebox(id, $treebox, this); + }, + + renderFull: function() { + var that = this; + this.$el.empty(); + + // Display tree and highlight the selected entity. + if (this.collection.getApplications().length == 0) { + this.$el.append(emptyTreeTemplate()); + + } else { + _.each(this.collection.getApplications(), function(appId) { + var entity = that.collection.get(appId); + var $treebox = getOrCreateApplicationTreebox(entity.id, entity.name, that); + updateTreeboxContent(entity, $treebox, that); + }); + } + + // Select the first app if there's no current selection. + if (!this.selectedEntityId) { + var firstApp = _.first(this.collection.getApplications()); + if (firstApp) + this.selectEntity(firstApp); + } + return this; + }, + + cancelHoverTimer: function() { + if (this.hoverTimer != null) { + clearTimeout(this.hoverTimer); + this.hoverTimer = null; + } + }, + + treeChange: function(event) { + var $target = $(event.currentTarget); + var $treeBox = $target.closest('.tree-box'); + if ($target.hasClass('tr-expand')) { + this.showChildrenOf($treeBox, false); + } else if ($target.hasClass('tr-expand-all')) { + this.showChildrenOf($treeBox, true); + } else if ($target.hasClass('tr-collapse')) { + this.hideChildrenOf($treeBox, false); + } else if ($target.hasClass('tr-collapse-all')) { + this.hideChildrenOf($treeBox, true); + } else { + // default - toggle + if ($treeBox.children('.node-children').is(':visible')) { + this.hideChildrenOf($treeBox, false); + } else { + this.showChildrenOf($treeBox, false); + } + } + // hide the popup menu + this.cancelHoverTimer(); + $('.light-popup').hide(); + // don't let other events interfere + return false; + }, + + showChildrenOf: function($treeBox, recurse, excludedEntityIds) { + excludedEntityIds = excludedEntityIds || []; + var idToExpand = $treeBox.data('entityId'); + var $wrapper = $treeBox.children('.entity_tree_node_wrapper'); + var $childContainer = $treeBox.children('.node-children'); + var model = this.collection.get(idToExpand); + if (model == null) { + // not yet loaded; parallel thread should load + return; + } + + var that = this; + var children = model.get('children'); // entity summaries: {id: ..., name: ...} + var renderChildrenAsIndirect = $treeBox.hasClass("indirect"); + _.each(children, function(child) { + var $treebox = getOrCreateChildTreebox(child.id, child.name, renderChildrenAsIndirect, $treeBox); + var model = that.collection.get(child.id); + if (model) { + updateTreeboxContent(model, $treebox, that); + } + }); + var members = model.get('members'); // entity summaries: {id: ..., name: ...} + _.each(members, function(member) { + var $treebox = getOrCreateChildTreebox(member.id, member.name, true, $treeBox); + var model = that.collection.get(member.id); + if (model) { + updateTreeboxContent(model, $treebox, that); + } + }); + + // Avoid infinite recursive expansion using a "taboo list" of indirect entities already expanded in this + // operation. Example: a group that contains itself or one of its own ancestors. Such cycles can only + // originate via "indirect" subordinates. + var expandIfNotExcluded = function($treebox, excludedEntityIds, defer) { + if ($treebox.hasClass('indirect')) { + var id = $treebox.data('entityId'); + if (_.contains(excludedEntityIds, id)) + return; + excludedEntityIds.push(id); + } + var doExpand = function() { that.showChildrenOf($treebox, recurse, excludedEntityIds); }; + if (defer) _.defer(doExpand); + else doExpand(); + }; + + if (this.collection.includeEntities(_.union(children, members))) { + // we have to load entities before we can proceed + this.collection.fetch({ + success: function() { + if (recurse) { + $childContainer.children('.tree-box').each(function () { + expandIfNotExcluded($(this), excludedEntityIds, true); + }); + } + } + }); + } + + $childContainer.slideDown(300); + $wrapper.find('.tree-node-state').removeClass('icon-chevron-right').addClass('icon-chevron-down'); + if (recurse) { + $childContainer.children('.tree-box').each(function () { + expandIfNotExcluded($(this), excludedEntityIds, false); + }); + } + }, + + hideChildrenOf: function($treeBox, recurse) { + var $wrapper = $treeBox.children('.entity_tree_node_wrapper'); + var $childContainer = $treeBox.children('.node-children'); + if (recurse) { + var that = this; + $childContainer.children('.tree-box').each(function () { + that.hideChildrenOf($(this), recurse); + }); + } + $childContainer.slideUp(300); + $wrapper.find('.tree-node-state').removeClass('icon-chevron-down').addClass('icon-chevron-right'); + }, + + }); + +}); http://git-wip-us.apache.org/repos/asf/brooklyn-ui/blob/18b073a9/src/main/webapp/assets/js/view/catalog.js ---------------------------------------------------------------------- diff --git a/src/main/webapp/assets/js/view/catalog.js b/src/main/webapp/assets/js/view/catalog.js new file mode 100644 index 0000000..7d4ab2a --- /dev/null +++ b/src/main/webapp/assets/js/view/catalog.js @@ -0,0 +1,613 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. +*/ +define([ + "underscore", "jquery", "backbone", "brooklyn", + "model/location", "model/entity", + "text!tpl/catalog/page.html", + "text!tpl/catalog/details-entity.html", + "text!tpl/catalog/details-generic.html", + "text!tpl/catalog/details-location.html", + "text!tpl/catalog/add-catalog-entry.html", + "text!tpl/catalog/add-yaml.html", + "text!tpl/catalog/add-location.html", + "text!tpl/catalog/nav-entry.html", + + "bootstrap", "jquery-form" +], function(_, $, Backbone, Brooklyn, + Location, Entity, + CatalogPageHtml, DetailsEntityHtml, DetailsGenericHtml, LocationDetailsHtml, + AddCatalogEntryHtml, AddYamlHtml, AddLocationHtml, EntryHtml) { + + // Holds the currently active details type, e.g. applications, policies. Bit of a workaround + // to share the active view with all instances of AccordionItemView, so clicking the 'reload + // catalog' button (handled by the parent of the AIVs) does not apply the 'active' class to + // more than one element. + var activeDetailsView; + + var CatalogItemDetailsView = Backbone.View.extend({ + + events: { + "click .delete": "deleteItem" + }, + + initialize: function() { + _.bindAll(this); + this.options.template = _.template(this.options.template || DetailsGenericHtml); + }, + + render: function() { + if (!this.options.model) { + return this.renderEmpty(); + } else { + return this.renderDetails(); + } + }, + + renderEmpty: function(extraMessage) { + this.$el.html("<div class='catalog-details'>" + + "<h3>Select an entry on the left</h3>" + + (extraMessage ? extraMessage : "") + + "</div>"); + return this; + }, + + renderDetails: function() { + var that = this, + model = this.options.model, + template = this.options.template; + var show = function() { + // Keep the previously open section open between items. Duplication between + // here and setDetailsView, below. This case handles view refreshes from this + // view directly (e.g. when indicating an error), below handles keeping the + // right thing open when navigating from view to view. + var open = this.$(".in").attr("id"); + var newHtml = $(template({model: model, viewName: that.options.name})); + $(newHtml).find("#"+open).addClass("in"); + that.$el.html(newHtml); + // rewire events. previous callbacks are removed automatically. + that.delegateEvents() + }; + + this.activeModel = model; + // Load the view with currently available data and refresh once the load is complete. + // Only refreshes the view if the model changes and the user hasn't selected another + // item while the load was executing. + show(); + model.on("change", function() { + if (that.activeModel.cid === model.cid) { + show(); + } + }); + model.fetch() + .fail(function(xhr, textStatus, errorThrown) { + console.log("error loading", model.id, ":", errorThrown); + if (that.activeModel.cid === model.cid) { + model.error = true; + show(); + } + }) + // Runs after the change event fires, or after the xhr completes + .always(function () { + model.off("change"); + }); + return this; + }, + + deleteItem: function(event) { + // Could use wait flag to block removal of model from collection + // until server confirms deletion and success handler to perform + // removal. Useful if delete fails for e.g. lack of entitlement. + var that = this; + var displayName = $(event.currentTarget).data("name") || "item"; + this.activeModel.destroy({ + success: function() { + that.renderEmpty("Deleted " + displayName); + }, + error: function(info) { + that.renderEmpty("Unable to permanently delete " + displayName+". Deletion is temporary, client-side only."); + } + }); + } + }); + + var AddCatalogEntryView = Backbone.View.extend({ + template: _.template(AddCatalogEntryHtml), + events: { + "click .show-context": "showContext" + }, + initialize: function() { + _.bindAll(this); + }, + render: function (initialView) { + this.$el.html(this.template()); + if (initialView) { + if (initialView == "entity") initialView = "yaml"; + + this.$("[data-context='"+initialView+"']").addClass("active"); + this.showFormForType(initialView) + } + return this; + }, + clearWithHtml: function(template) { + if (this.contextView) this.contextView.close(); + this.context = undefined; + this.$(".btn").removeClass("active"); + this.$("#catalog-add-form").html(template); + }, + beforeClose: function () { + if (this.contextView) this.contextView.close(); + }, + showContext: function(event) { + var $event = $(event.currentTarget); + var context = $event.data("context"); + if (this.context !== context) { + if (this.contextView) { + this.contextView.close(); + } + this.showFormForType(context) + } + }, + showFormForType: function (type) { + this.context = type; + if (type == "yaml" || type == "entity") { + this.contextView = newYamlForm(this, this.options.parent); + } else if (type == "location") { + this.contextView = newLocationForm(this, this.options.parent); + } else if (type !== undefined) { + console.log("unknown catalog type " + type); + this.showFormForType("yaml"); + return; + } + Backbone.history.navigate("/v1/catalog/new/" + type); + this.$("#catalog-add-form").html(this.contextView.$el); + } + }); + + function newYamlForm(addView, addViewParent) { + return new Brooklyn.view.Form({ + template: _.template(AddYamlHtml), + onSubmit: function (model) { + var submitButton = this.$(".catalog-submit-button"); + // "loading" is an indicator to Bootstrap, not a string to display + submitButton.button("loading"); + var self = this; + var options = { + url: "/v1/catalog/", + data: model.get("yaml"), + processData: false, + type: "post" + }; + $.ajax(options) + .done(function (data, status, xhr) { + // Can extract location of new item with: + //model.url = Brooklyn.util.pathOf(xhr.getResponseHeader("Location")); + if (_.size(data)==0) { + addView.clearWithHtml( "No items supplied." ); + } else { + addView.clearWithHtml( "Added: "+_.escape(_.keys(data).join(", ")) + + (_.size(data)==1 ? ". Loading..." : "") ); + addViewParent.loadAnyAccordionItem(_.size(data)==1 ? _.keys(data)[0] : undefined); + } + }) + .fail(function (xhr, status, error) { + submitButton.button("reset"); + self.$(".catalog-save-error") + .removeClass("hide") + .find(".catalog-error-message") + .html(_.escape(Brooklyn.util.extractError(xhr, "Could not add catalog item:\n'n" + error))); + }); + } + }); + } + + // Could adapt to edit existing locations too. + function newLocationForm(addView, addViewParent) { + // Renders with config key list + var body = new (Backbone.View.extend({ + beforeClose: function() { + if (this.configKeyList) { + this.configKeyList.close(); + } + }, + render: function() { + this.configKeyList = new Brooklyn.view.ConfigKeyInputPairList().render(); + var template = _.template(AddLocationHtml); + this.$el.html(template); + this.$("#new-location-config").html(this.configKeyList.$el); + }, + showError: function (message) { + self.$(".catalog-save-error") + .removeClass("hide") + .find(".catalog-error-message") + .html(message); + } + })); + var form = new Brooklyn.view.Form({ + body: body, + model: Location.Model, + onSubmit: function (location) { + var configKeys = body.configKeyList.getConfigKeys(); + if (!configKeys.displayName) { + configKeys.displayName = location.get("name"); + } + var submitButton = this.$(".catalog-submit-button"); + // "loading" is an indicator to Bootstrap, not a string to display + submitButton.button("loading"); + location.set("config", configKeys); + location.save() + .done(function (data) { + addView.clearWithHtml( "Added: "+data.id+". Loading..." ); + addViewParent.loadAccordionItem("locations", data.id); + }) + .fail(function (response) { + submitButton.button("reset"); + body.showError(Brooklyn.util.extractError(response)); + }); + } + }); + + return form; + } + + var Catalog = Backbone.Collection.extend({ + modelX: Backbone.Model.extend({ + url: function() { + return "/v1/catalog/" + this.name + "/" + this.id + "?allVersions=true"; + } + }), + initialize: function(models, options) { + this.name = options["name"]; + if (!this.name) { + throw new Error("Catalog collection must know its name"); + } + //this.model is a constructor so it shouldn't be _.bind'ed to this + //It actually works when a browser provided .bind is used, but the + //fallback implementation doesn't support it. + var that = this; + var model = this.model.extend({ + url: function() { + return "/v1/catalog/" + that.name + "/" + this.id.split(":").join("/"); + } + }); + _.bindAll(this); + this.model = model; + }, + url: function() { + return "/v1/catalog/" + this.name+"?allVersions=true"; + } + }); + + /** Use to fill single accordion view list. */ + var AccordionItemView = Backbone.View.extend({ + tag: "div", + className: "accordion-item", + events: { + 'click .accordion-head': 'toggle', + 'click .accordion-nav-row': 'showDetails' + }, + bodyTemplate: _.template( + "<div class='accordion-head capitalized'><%= name %></div>" + + "<div class='accordion-body' style='display: <%= display %>'></div>"), + + initialize: function() { + _.bindAll(this); + this.name = this.options.name; + if (!this.name) { + throw new Error("Name should have been given for accordion entry"); + } else if (!this.options.onItemSelected) { + throw new Error("onItemSelected(model, element) callback should have been given for accordion entry"); + } + + // Generic templates + this.template = _.template(this.options.template || EntryHtml); + + // Returns template applied to function arguments. Alter if collection altered. + // Will be run in the context of the AccordionItemView. + this.entryTemplateArgs = this.options.entryTemplateArgs || function(model, index) { + return {type: model.getVersionedAttr("type"), id: model.get("id")}; + }; + + // undefined argument is used for existing model items + var collectionModel = this.options.model || Backbone.Model; + this.collection = this.options.collection || new Catalog(undefined, { + name: this.name, + model: collectionModel + }); + // Refreshes entries list when the collection is synced with the server or + // any of its members are destroyed. + this.collection + .on("sync", this.renderEntries) + .on("destroy", this.renderEntries); + this.refresh(); + }, + + beforeClose: function() { + this.collection.off(); + }, + + render: function() { + this.$el.html(this.bodyTemplate({ + name: this.name, + display: this.options.autoOpen ? "block" : "none" + })); + this.renderEntries(); + return this; + }, + + singleItemTemplater: function(isChild, model, index) { + var args = _.extend({ + cid: model.cid, + isChild: isChild, + extraClasses: (activeDetailsView == this.name && model.cid == this.activeCid) ? "active" : "" + }, this.entryTemplateArgs(model)); + return this.template(args); + }, + + renderEntries: function() { + var elements = this.collection.map(_.partial(this.singleItemTemplater, false), this); + this.updateContent(elements.join('')); + }, + + updateContent: function(markup) { + this.$(".accordion-body") + .empty() + .append(markup); + }, + + refresh: function() { + this.collection.fetch(); + }, + + showDetails: function(event) { + var $event = $(event.currentTarget); + var cid = $event.data("cid"); + if (activeDetailsView !== this.name || this.activeCid !== cid) { + activeDetailsView = this.name; + this.activeCid = cid; + var model = this.collection.get(cid); + Backbone.history.navigate("v1/catalog/" + this.name + "/" + model.id); + this.options.onItemSelected(activeDetailsView, model, $event); + } + }, + + toggle: function() { + var body = this.$(".accordion-body"); + var hidden = this.hidden = body.css("display") == "none"; + if (hidden) { + body.removeClass("hide").slideDown('fast'); + } else { + body.slideUp('fast') + } + }, + + show: function() { + var body = this.$(".accordion-body"); + var hidden = this.hidden = body.css("display") == "none"; + if (hidden) { + body.removeClass("hide").slideDown('fast'); + } + } + }); + + var AccordionEntityView = AccordionItemView.extend({ + renderEntries: function() { + var symbolicNameFn = function(model) {return model.get("symbolicName")}; + var groups = this.collection.groupBy(symbolicNameFn); + var orderedIds = _.uniq(this.collection.map(symbolicNameFn)); + + function getLatestStableVersion(items) { + //the server sorts items by descending version, snapshots at the back + return items[0]; + } + + var catalogTree = _.map(orderedIds, function(symbolicName) { + var group = groups[symbolicName]; + var root = getLatestStableVersion(group); + var children = _.reject(group, function(model) {return root.id == model.id;}); + return {root: root, children: children}; + }); + + var templater = function(memo, item, index) { + memo.push(this.singleItemTemplater(false, item.root)); + return memo.concat(_.map(item.children, _.partial(this.singleItemTemplater, true), this)); + }; + + var elements = _.reduce(catalogTree, templater, [], this); + this.updateContent(elements.join('')); + } + }); + + // Controls whole page. Parent of accordion items and details view. + var CatalogResourceView = Backbone.View.extend({ + tagName:"div", + className:"container container-fluid", + entryTemplate:_.template(EntryHtml), + + events: { + 'click .refresh':'refresh', + 'click #add-new-thing': 'createNewThing' + }, + + initialize: function() { + $(".nav1").removeClass("active"); + $(".nav1_catalog").addClass("active"); + // Important that bind happens before accordion object is created. If it happens after + // `this' will not be set correctly for the onItemSelected callbacks. + _.bindAll(this); + this.accordion = this.options.accordion || { + "applications": new AccordionEntityView({ + name: "applications", + singular: "application", + onItemSelected: _.partial(this.showCatalogItem, DetailsEntityHtml), + model: Entity.Model, + autoOpen: !this.options.kind || this.options.kind == "applications" + }), + "entities": new AccordionEntityView({ + name: "entities", + singular: "entity", + onItemSelected: _.partial(this.showCatalogItem, DetailsEntityHtml), + model: Entity.Model, + autoOpen: this.options.kind == "entities" + }), + "policies": new AccordionEntityView({ + // TODO needs parsing, and probably its own model + // but cribbing "entity" works for now + // (and not setting a model can cause errors intermittently) + onItemSelected: _.partial(this.showCatalogItem, DetailsEntityHtml), + name: "policies", + singular: "policy", + model: Entity.Model, + autoOpen: this.options.kind == "policies" + }), + "locations": new AccordionItemView({ + name: "locations", + singular: "location", + onItemSelected: _.partial(this.showCatalogItem, LocationDetailsHtml), + collection: this.options.locations, + autoOpen: this.options.kind == "locations", + entryTemplateArgs: function (location, index) { + return { + type: location.getIdentifierName(), + id: location.getLinkByName("self") + }; + } + }) + }; + }, + + beforeClose: function() { + _.invoke(this.accordion, 'close'); + }, + + render: function() { + this.$el.html(_.template(CatalogPageHtml, {})); + var parent = this.$(".catalog-accordion-parent"); + _.each(this.accordion, function(child) { + parent.append(child.render().$el); + }); + if (this.options.kind === "new") { + this.createNewThing(this.options.id); + } else if (this.options.kind && this.options.id) { + this.loadAccordionItem(this.options.kind, this.options.id) + } else { + // Show empty details view to start + this.setDetailsView(new CatalogItemDetailsView().render()); + } + return this + }, + + /** Refreshes the contents of each accordion pane */ + refresh: function() { + _.invoke(this.accordion, 'refresh'); + }, + + createNewThing: function (type) { + // Discard if it's the jquery event object. + if (!_.isString(type)) { + type = undefined; + } + var viewName = "createNewThing"; + if (!type) { + Backbone.history.navigate("/v1/catalog/new"); + } + activeDetailsView = viewName; + this.$(".accordion-nav-row").removeClass("active"); + var newView = new AddCatalogEntryView({ + parent: this + }).render(type); + this.setDetailsView(newView); + }, + + loadAnyAccordionItem: function (id) { + this.loadAccordionItem("entities", id); + this.loadAccordionItem("applications", id); + this.loadAccordionItem("policies", id); + this.loadAccordionItem("locations", id); + }, + + loadAccordionItem: function (kind, id) { + if (!this.accordion[kind]) { + console.error("No accordion for: " + kind); + } else { + var accordion = this.accordion[kind]; + var self = this; + // reset is needed because we rely on server's ordering; + // without it, server additions are placed at end of list + accordion.collection.fetch({reset: true}) + .then(function() { + var model = accordion.collection.get(id); + if (!model) { + // if a version is supplied, try it without a version - needed for locations, navigating after deletion + if (id && id.split(":").length>1) { + model = accordion.collection.get( id.split(":")[0] ); + } + } + if (!model) { + // if an ID is supplied without a version, look for first matching version (should be newest) + if (id && id.split(":").length==1 && accordion.collection.models) { + model = _.find(accordion.collection.models, function(m) { + return m && m.id && m.id.startsWith(id+":"); + }); + } + } + // TODO could look in collection for any starting with ID + if (model) { + Backbone.history.navigate("/v1/catalog/"+kind+"/"+id); + activeDetailsView = kind; + accordion.activeCid = model.cid; + accordion.options.onItemSelected(kind, model); + accordion.show(); + } else { + // catalog item not found, or not found yet (it might be reloaded and another callback will try again) + } + }); + } + }, + + showCatalogItem: function(template, viewName, model, $target) { + this.$(".accordion-nav-row").removeClass("active"); + if ($target) { + $target.addClass("active"); + } else { + this.$("[data-cid=" + model.cid + "]").addClass("active"); + } + var newView = new CatalogItemDetailsView({ + model: model, + template: template, + name: viewName + }).render(); + this.setDetailsView(newView) + }, + + setDetailsView: function(view) { + this.$("#details").html(view.el); + if (this.detailsView) { + // Try to re-open sections that were previously visible. + var openedItem = this.detailsView.$(".in").attr("id"); + if (openedItem) { + view.$("#" + openedItem).addClass("in"); + } + this.detailsView.close(); + } + this.detailsView = view; + } + }); + + return CatalogResourceView +});
