| /* |
| * 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", |
| "view/location-wizard", |
| "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, |
| LocationWizard, |
| 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 .composer": "composeItem", |
| "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>" + |
| _.escape(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; |
| }, |
| |
| composeItem: function(event) { |
| // TODO we should store `catalogYaml` in the catalog so these can be edited |
| // for now locations are edited, but other items are deployed (ugly and inconsistent) |
| if (this.options.name == "locations") { |
| // old ui has planYaml inside a catalog block |
| this.composerEditItem(event, this.options.model.get("catalog")); |
| } else { |
| // deploy the item |
| this.composerDeployItem(event); |
| } |
| }, |
| composerDeployItem: function(event) { |
| console.log("composer deploy", event.currentTarget); |
| Backbone.history.navigate("/v1/editor/app/"+ encodeURIComponent($(event.currentTarget).data("name")), |
| {trigger: true}); |
| }, |
| composerEditItem: function(event, catalog) { |
| // TODO ideally we get a catalogYaml from the object instead of recreating |
| var planYaml = catalog.planYaml; |
| |
| // remove a brooklyn.locations: - ... prefix which brooklyn may insert for legacy reasons, |
| // replace with single blank line |
| if (planYaml.trim().startsWith("brooklyn.locations:")) { |
| planYaml = planYaml.replace(/^ *brooklyn.locations:( *\n)+/, "") |
| planYaml = "\n"+planYaml.replace(/^( *)(-)( *)/, "$1 $3") |
| } |
| var yaml = "brooklyn.catalog:\n"+ |
| " items:\n"+ |
| " - id: " + catalog.symbolicName + "\n"+ |
| " # NB: the version may need to be increased\n"+ |
| " version: " + catalog.version + "\n"+ |
| (catalog.description ? " description: " + catalog.description + "\n" : "") + |
| (catalog.iconUrl ? " iconUrl: " + catalog.iconUrl + "\n" : "") + |
| // any other fields? |
| " itemType: location\n"+ |
| " item:\n"+ |
| this.prefixAllLines(" ", planYaml); |
| Backbone.history.navigate("/v1/editor/catalog/_/"+ encodeURIComponent(yaml), |
| {trigger: true}); |
| }, |
| prefixAllLines: function(prefix, lines) { |
| return prefix+lines.split("\n").join("\n"+prefix); |
| Backbone.history.navigate("/v1/editor/app/"+ encodeURIComponent($(event.currentTarget).data("name")), |
| {trigger: true}); |
| }, |
| |
| 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='"+_.escape(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 == "location") { |
| this.contextView = newLocationForm(this, this.options.parent); |
| Backbone.history.navigate("/v1/catalog/new/" + encodeURIComponent(type)); |
| this.$("#catalog-add-form").html(this.contextView.$el); |
| }else{ |
| // go to composer |
| Backbone.history.navigate('/v1/editor/catalog/', {trigger: true}); |
| } |
| } |
| }); |
| |
| // Could adapt to edit existing locations too. |
| function newLocationForm(addView, addViewParent) { |
| return new LocationWizard({ |
| onLocationCreated: function(wizard, data) { |
| addViewParent.loadAccordionItem("locations", data.id, true); |
| }, |
| onFinish: function(wizard, data) { |
| addViewParent.loadAccordionItem("locations", data.id); |
| addView.clearWithHtml( "Added: "+_.escape(data.id)+". Loading..." ); |
| } |
| }).render(); |
| } |
| |
| var Catalog = Backbone.Collection.extend({ |
| modelX: Backbone.Model.extend({ |
| url: function() { |
| return "/v1/catalog/" + encodeURIComponent(this.name) + "/" + encodeURIComponent(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/" + encodeURIComponent(that.name) + "/" + encodeURIComponent(this.id).split(encodeURIComponent(":")).join("/"); |
| } |
| }); |
| _.bindAll(this); |
| this.model = model; |
| }, |
| url: function() { |
| return "/v1/catalog/" + encodeURIComponent(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/" + encodeURIComponent(this.name) + "/" + encodeURIComponent(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" |
| }), |
| "enrichers": 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: "enrichers", |
| singular: "enricher", |
| model: Entity.Model, |
| autoOpen: this.options.kind == "enrichers" |
| }), |
| "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("enrichers", id); |
| this.loadAccordionItem("locations", id); |
| }, |
| |
| loadAccordionItem: function (kind, id, noRedirect) { |
| 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 && !noRedirect) { |
| Backbone.history.navigate("/v1/catalog/" + encodeURIComponent(kind) + "/" + encodeURIComponent(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='" + _.escape(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 |
| }); |