You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@brooklyn.apache.org by he...@apache.org on 2016/02/01 18:52:21 UTC
[03/51] [abbrv] [partial] brooklyn-ui git commit: move subdir from
incubator up a level as it is promoted to its own repo (first non-incubator
commit!)
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
+});