blob: 284784c642dd880e2094d0561987ce5ee5074bcc [file] [log] [blame]
/*
* 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
});