help text support for detail view, tenant view warnings about changing slivers
[plstackapi.git] / planetstack / core / xoslib / static / js / xoslib / xosHelper.js
index c799794..76254f6 100644 (file)
+HTMLView = Marionette.ItemView.extend({
+  render: function() {
+      this.$el.append(this.options.html);
+  },
+});
+
+SliceSelectorOption = Marionette.ItemView.extend({
+    template: "#xos-sliceselector-option",
+    tagName: "option",
+    attributes: function() {
+        if (this.options.selectedID == this.model.get("id")) {
+            return { value: this.model.get("id"), selected: 1 };
+        } else {
+            return { value: this.model.get("id") };
+        }
+    },
+});
+
+SliceSelectorView = Marionette.CompositeView.extend({
+    template: "#xos-sliceselector-select",
+    childViewContainer: "select",
+    childView: SliceSelectorOption,
+    caption: "Slice",
+
+    events: {"change select": "onSliceChanged"},
+
+    childViewOptions: function() {
+        return { selectedID: this.options.selectedID || this.selectedID || null };
+    },
+
+    onSliceChanged: function() {
+        this.sliceChanged(this.$el.find("select").val());
+    },
+
+    sliceChanged: function(id) {
+        console.log("sliceChanged " + id);
+    },
+
+    templateHelpers: function() { return {caption: this.options.caption || this.caption }; },
+});
+
+FilteredCompositeView = Marionette.CompositeView.extend( {
+    showCollection: function() {
+      var ChildView;
+      this.collection.each(function(child, index) {
+        if (this.filter && !this.filter(child)) {
+            return;
+        }
+        ChildView = this.getChildView(child);
+        this.addChild(child, ChildView, index);
+      }, this);
+
+    },
+});
+
+XOSRouter = Marionette.AppRouter.extend({
+        initialize: function() {\r
+            this.routeStack=[];\r
+        },\r
+\r
+        onRoute: function(x,y,z) {\r
+             this.routeStack.push(Backbone.history.fragment);\r
+             this.routeStack = this.routeStack.slice(-32);   // limit the size of routeStack to something reasonable\r
+        },\r
+\r
+        prevPage: function() {\r
+             return this.routeStack.slice(-1)[0];
+        },
+
+        showPreviousURL: function() {
+            prevPage = this.prevPage();
+            //console.log("showPreviousURL");
+            //console.log(this.routeStack);
+            if (prevPage) {
+                this.navigate("#"+prevPage, {trigger: false, replace: true} );
+            }
+        },
+
+        navigate: function(href, options) {
+            if (options.force) {
+                Marionette.AppRouter.prototype.navigate.call(this, "nowhere", {trigger: false, replace: true});
+            }
+            Marionette.AppRouter.prototype.navigate.call(this, href, options);
+        },
+    });\r
+\r
+// XXX - We import backbone multiple times (BAD!) since the import happens\r
+//   inside of the view's html. The second time it's imported (developer\r
+//   view), it wipes out Backbone.Syphon. So, save it as Backbone_Syphon for\r
+//   now.\r
+Backbone_Syphon = Backbone.Syphon\r
+Backbone_Syphon.InputReaders.register('select', function(el) {\r
+    // Modify syphon so that if a select has "syphonall" in the class, then
+    // the value of every option will be returned, regardless of whether of
+    // not it is selected.
+    if (el.hasClass("syphonall")) {
+        result = [];
+        _.each(el.find("option"), function(option) {
+            result.push($(option).val());
+        });
+        return result;
+    }
+    return el.val();
+});
+
 XOSApplication = Marionette.Application.extend({
+    detailBoxId: "#detailBox",
     errorBoxId: "#errorBox",
     errorCloseButtonId: "#close-error-box",
     successBoxId: "#successBox",
     successCloseButtonId: "#close-success-box",
     errorTemplate: "#xos-error-template",
     successTemplate: "#xos-success-template",
+    logMessageCount: 0,
 
-    hideError: function(result) {
-        $(this.errorBoxId).hide();
-        $(this.successBoxId).hide();
+    confirmDialog: function(view, event, callback) {
+        $("#xos-confirm-dialog").dialog({
+           autoOpen: false,
+           modal: true,
+           buttons : {
+                "Confirm" : function() {
+                  $(this).dialog("close");
+                  if (event) {
+                      view.trigger(event);
+                  }
+                  if (callback) {
+                      callback();
+                  }
+                },
+                "Cancel" : function() {
+                  $(this).dialog("close");
+                }
+              }
+            });
+        $("#xos-confirm-dialog").dialog("open");
+    },
+
+    popupErrorDialog: function(responseText) {
+        try {
+            parsed_error=$.parseJSON(responseText);
+            width=300;
+        }
+        catch(err) {
+            parsed_error=undefined;
+            width=640;    // django stacktraces like wide width
+        }
+        console.log(responseText);
+        console.log(parsed_error);
+        if (parsed_error) {
+            $("#xos-error-dialog").html(templateFromId("#xos-error-response")(parsed_error));
+        } else {
+            $("#xos-error-dialog").html(templateFromId("#xos-error-rawresponse")({responseText: responseText}))
+        }
+
+        $("#xos-error-dialog").dialog({
+            modal: true,
+            width: width,
+            buttons: {
+                Ok: function() { $(this).dialog("close"); }
+            }
+        });
+    },
+
+    hideLinkedItems: function(result) {
+        var index=0;
+        while (index<4) {
+            this["linkedObjs" + (index+1)].empty();
+            index = index + 1;
+        }
+    },
+
+    hideTabs: function() { $("#tabs").hide(); },
+    showTabs: function() { $("#tabs").show(); },
+
+    createListHandler: function(listViewName, collection_name, regionName, title) {
+        var app=this;
+        return function() {
+            listView = new app[listViewName];
+            app[regionName].show(listView);
+            app.hideLinkedItems();
+            $("#contentTitle").html(templateFromId("#xos-title-list")({"title": title}));
+            $("#detail").show();
+            app.hideTabs();
+
+            listButtons = new XOSListButtonView({linkedView: listView});
+            app["rightButtonPanel"].show(listButtons);
+        }
+    },
+
+    createAddHandler: function(detailName, collection_name, regionName, title) {
+        var app=this;
+        return function() {
+            console.log("addHandler");
+
+            app.hideLinkedItems();
+            app.hideTabs();
+
+            model = new xos[collection_name].model();
+            detailViewClass = app[detailName];
+            detailView = new detailViewClass({model: model, collection:xos[collection_name]});
+            app[regionName].show(detailView);
+
+            detailButtons = new XOSDetailButtonView({linkedView: detailView});
+            app["rightButtonPanel"].show(detailButtons);
+        }
+    },
+
+    createAddChildHandler: function(addChildName, collection_name) {
+        var app=this;
+        return function(parent_modelName, parent_fieldName, parent_id) {
+            app.Router.showPreviousURL();
+            model = new xos[collection_name].model();
+            model.attributes[parent_fieldName] = parent_id;
+            model.readOnlyFields.push(parent_fieldName);
+            detailViewClass = app[addChildName];
+            var detailView = new detailViewClass({model: model, collection:xos[collection_name]});
+            detailView.dialog = $("xos-addchild-dialog");
+            app["addChildDetail"].show(detailView);
+            $("#xos-addchild-dialog").dialog({
+               autoOpen: false,
+               modal: true,
+               width: 640,
+               buttons : {
+                    "Save" : function() {
+                      var addDialog = this;
+                      detailView.synchronous = true;
+                      detailView.afterSave = function() { console.log("addChild afterSave"); $(addDialog).dialog("close"); }
+                      detailView.save();
+
+                      //$(this).dialog("close");
+                    },
+                    "Cancel" : function() {
+                      $(this).dialog("close");
+                    }
+                  }
+                });
+            $("#xos-addchild-dialog").dialog("open");
+        }
+    },
+
+    createDeleteHandler: function(collection_name) {
+        var app=this;
+        return function(model_id) {
+            console.log("deleteCalled");
+            collection = xos[collection_name];
+            model = collection.get(model_id);
+            assert(model!=undefined, "failed to get model " + model_id + " from collection " + collection_name);
+            app.Router.showPreviousURL();
+            app.deleteDialog(model);
+        }
+    },
+
+    createDetailHandler: function(detailName, collection_name, regionName, title) {
+        var app=this;
+        showModelId = function(model_id) {
+            $("#contentTitle").html(templateFromId("#xos-title-detail")({"title": title}));
+
+            collection = xos[collection_name];
+            model = collection.get(model_id);
+            if (model == undefined) {
+                app[regionName].show(new HTMLView({html: "failed to load object " + model_id + " from collection " + collection_name}));
+            } else {
+                detailViewClass = app[detailName];
+                detailView = new detailViewClass({model: model});
+                app[regionName].show(detailView);
+                detailView.showLinkedItems();
+
+                detailButtons = new XOSDetailButtonView({linkedView: detailView});
+                app["rightButtonPanel"].show(detailButtons);
+            }
+        }
+        return showModelId;
+    },
+
+    /* error handling callbacks */
+
+    hideError: function() {
+        if (this.logWindowId) {
+        } else {
+            $(this.errorBoxId).hide();
+            $(this.successBoxId).hide();
+        }
     },
 
     showSuccess: function(result) {
-         $(this.successBoxId).show();
-         $(this.successBoxId).html(_.template($(this.successTemplate).html())(result));
-         $(this.successCloseButtonId).unbind().bind('click', function() {
-             $(this.successBoxId).hide();
-         });
+         result["statusclass"] = "success";
+         if (this.logTableId) {
+             this.appendLogWindow(result);
+         } else {
+             $(this.successBoxId).show();
+             $(this.successBoxId).html(_.template($(this.successTemplate).html())(result));
+             var that=this;
+             $(this.successCloseButtonId).unbind().bind('click', function() {
+                 $(that.successBoxId).hide();
+             });
+         }
     },
 
     showError: function(result) {
-         $(this.errorBoxId).show();
-         $(this.errorBoxId).html(_.template($(this.errorTemplate).html())(result));
-         $(this.errorCloseButtonId).unbind().bind('click', function() {
-             $(this.errorBoxId).hide();
-         });
+         result["statusclass"] = "failure";
+         if (this.logTableId) {
+             this.appendLogWindow(result);
+             this.popupErrorDialog(result.responseText);
+         } else {
+             // this is really old stuff
+             $(this.errorBoxId).show();
+             $(this.errorBoxId).html(_.template($(this.errorTemplate).html())(result));
+             var that=this;
+             $(this.errorCloseButtonId).unbind().bind('click', function() {
+                 $(that.errorBoxId).hide();
+             });
+         }
+    },
+
+    showInformational: function(result) {
+         result["statusclass"] = "inprog";
+         if (this.logTableId) {
+             return this.appendLogWindow(result);
+         } else {
+             return undefined;
+         }
+    },
+
+    appendLogWindow: function(result) {
+        // compute a new logMessageId for this log message
+        logMessageId = "logMessage" + this.logMessageCount;
+        this.logMessageCount = this.logMessageCount + 1;
+        result["logMessageId"] = logMessageId;
+
+        logMessageTemplate=$("#xos-log-template").html();
+        assert(logMessageTemplate != undefined, "logMessageTemplate is undefined");
+        newRow = _.template(logMessageTemplate, result);
+        assert(newRow != undefined, "newRow is undefined");
+
+        if (result["infoMsgId"] != undefined) {
+            // We were passed the logMessageId of an informational message,
+            // and the caller wants us to replace that message with our own.
+            // i.e. replace an informational message with a success or an error.
+            $("#"+result["infoMsgId"]).replaceWith(newRow);
+        } else {
+            // Create a brand new log message rather than replacing one.
+            logTableBody = $(this.logTableId + " tbody");
+            logTableBody.prepend(newRow);
+        }
+
+        if (this.statusMsgId) {
+            $(this.statusMsgId).html( templateFromId("#xos-status-template")(result) );
+        }
+
+        limitTableRows(this.logTableId, 5);
+
+        return logMessageId;
+    },
+
+    saveError: function(model, result, xhr, infoMsgId) {
+        console.log("saveError");
+        result["what"] = "save " + model.modelName + " " + model.attributes.humanReadableName;
+        result["infoMsgId"] = infoMsgId;
+        this.showError(result);
+    },
+
+    saveSuccess: function(model, result, xhr, infoMsgId, addToCollection) {
+        console.log("saveSuccess");
+        if (model.addToCollection) {
+            console.log("addToCollection");
+            console.log(model.addToCollection);
+            model.addToCollection.add(model);
+            model.addToCollection.sort();
+            model.addToCollection = undefined;
+        }
+        result = {status: xhr.xhr.status, statusText: xhr.xhr.statusText};
+        result["what"] = "save " + model.modelName + " " + model.attributes.humanReadableName;
+        result["infoMsgId"] = infoMsgId;
+        this.showSuccess(result);
+    },
+
+    destroyError: function(model, result, xhr, infoMsgId) {
+        result["what"] = "destroy " + model.modelName + " " + model.attributes.humanReadableName;
+        result["infoMsgId"] = infoMsgId;
+        this.showError(result);
+    },
+
+    destroySuccess: function(model, result, xhr, infoMsgId) {
+        result = {status: xhr.xhr.status, statusText: xhr.xhr.statusText};
+        result["what"] = "destroy " + model.modelName + " " + model.attributes.humanReadableName;
+        result["infoMsgId"] = infoMsgId;
+        this.showSuccess(result);
+    },
+
+    /* end error handling callbacks */
+
+    destroyModel: function(model) {
+         //console.log("destroyModel"); console.log(model);
+         this.hideError();
+         var infoMsgId = this.showInformational( {what: "destroy " + model.modelName + " " + model.attributes.humanReadableName, status: "", statusText: "in progress..."} );
+         var that = this;
+         model.destroy({error: function(model, result, xhr) { that.destroyError(model,result,xhr,infoMsgId);},
+                        success: function(model, result, xhr) { that.destroySuccess(model,result,xhr,infoMsgId);}});
+    },
+
+    deleteDialog: function(model, afterDelete) {
+        var that=this;
+        assert(model!=undefined, "deleteDialog's model is undefined");
+        //console.log("deleteDialog"); console.log(model);
+        this.confirmDialog(null, null, function() {
+            //console.log("deleteConfirm"); console.log(model);
+            modelName = model.modelName;
+            that.destroyModel(model);
+            if (afterDelete=="list") {
+                that.navigate("list", modelName);
+            } else if (afterDelete) {
+                afterDelete();
+            }
+        });
+    },
+});
+
+XOSButtonView = Marionette.ItemView.extend({
+            events: {"click button.btn-xos-save-continue": "submitContinueClicked",
+                     "click button.btn-xos-save-leave": "submitLeaveClicked",
+                     "click button.btn-xos-save-another": "submitAddAnotherClicked",
+                     "click button.btn-xos-delete": "deleteClicked",
+                     "click button.btn-xos-add": "addClicked",
+                     "click button.btn-xos-refresh": "refreshClicked",
+                     },
+
+            submitLeaveClicked: function(e) {
+                     this.options.linkedView.submitLeaveClicked.call(this.options.linkedView, e);
+                     },
+
+            submitContinueClicked: function(e) {
+                     this.options.linkedView.submitContinueClicked.call(this.options.linkedView, e);
+                     },
+
+            submitAddAnotherClicked: function(e) {
+                     this.options.linkedView.submitAddAnotherClicked.call(this.options.linkedView, e);
+                     },
+
+            submitDeleteClicked: function(e) {
+                     this.options.linkedView.deleteClicked.call(this.options.linkedView, e);
+                     },
+
+            addClicked: function(e) {
+                     this.options.linkedView.addClicked.call(this.options.linkedView, e);
+                     },
+
+            refreshClicked: function(e) {
+                     this.options.linkedView.refreshClicked.call(this.options.linkedView, e);
+                     },
+            });
+
+XOSDetailButtonView = XOSButtonView.extend({ template: "#xos-savebuttons-template" });
+XOSListButtonView = XOSButtonView.extend({ template: "#xos-listbuttons-template" });
+
+/* XOSDetailView
+      extend with:
+         app - MarionetteApplication
+         template - template (See XOSHelper.html)
+*/
+
+XOSDetailView = Marionette.ItemView.extend({
+            tagName: "div",
+
+            viewInitializers: [],
+
+            events: {"click button.btn-xos-save-continue": "submitContinueClicked",
+                     "click button.btn-xos-save-leave": "submitLeaveClicked",
+                     "click button.btn-xos-save-another": "submitAddAnotherClicked",
+                     "click button.btn-xos-delete": "deleteClicked",
+                     "change input": "inputChanged"},
+
+            /* inputChanged is watching the onChange events of the input controls. We
+               do this to track when this view is 'dirty', so we can throw up a warning
+               if the user tries to change his slices without saving first.
+            */
+
+            initialize: function() {
+                this.on("saveSuccess", this.onSaveSuccess);
+                this.synchronous = false;
+            },
+
+            onShow: function() {
+                _.each(this.viewInitializers, function(initializer) {
+                    initializer();
+                });
+            },
+
+            saveSuccess: function(e) {
+                // always called after a save succeeds
+            },
+
+            afterSave: function(e) {
+                // if this.synchronous, then called after the save succeeds
+                // if !this.synchronous, then called after save is initiated
+            },
+
+            onSaveSuccess: function(e) {
+                this.saveSuccess(e);
+                if (this.synchronous) {
+                    this.afterSave(e);
+                }
+            },
+
+            inputChanged: function(e) {
+                this.dirty = true;
+            },
+
+            submitContinueClicked: function(e) {
+                console.log("saveContinue");
+                e.preventDefault();
+                this.afterSave = function() { };
+                this.save();
+            },
+
+            submitLeaveClicked: function(e) {
+                console.log("saveLeave");
+                e.preventDefault();
+                if (this.options.noSubmitButton || this.noSubmitButton) {
+                    return;
+                }
+                var that=this;
+                this.afterSave = function() {
+                    that.app.navigate("list", that.model.modelName);
+                }
+                this.save();
+            },
+
+            submitAddAnotherClicked: function(e) {
+                console.log("saveAnother");
+                console.log(this);
+                e.preventDefault();
+                var that=this;
+                this.afterSave = function() {
+                    console.log("addAnother afterSave");
+                    that.app.navigate("add", that.model.modelName);
+                }
+                this.save();
+            },
+
+            save: function() {
+                this.app.hideError();
+                var data = Backbone_Syphon.serialize(this);
+                var that = this;
+                var isNew = !this.model.id;
+
+                console.log(data);
+
+                this.$el.find(".help-inline").remove();
+
+                /* although model.validate() is called automatically by
+                   model.save, we call it ourselves, so we can throw up our
+                   validation error before creating the infoMsg in the log
+                */
+                errors =  this.model.xosValidate(data);
+                if (errors) {
+                    this.onFormDataInvalid(errors);
+                    return;
+                }
+
+                if (isNew) {
+                    this.model.attributes.humanReadableName = "new " + this.model.modelName;
+                    this.model.addToCollection = this.collection;
+                } else {
+                    this.model.addToCollection = undefined;
+                }
+
+                var infoMsgId = this.app.showInformational( {what: "save " + this.model.modelName + " " + this.model.attributes.humanReadableName, status: "", statusText: "in progress..."} );
+
+                this.model.save(data, {error: function(model, result, xhr) { that.app.saveError(model,result,xhr,infoMsgId);},
+                                       success: function(model, result, xhr) { that.app.saveSuccess(model,result,xhr,infoMsgId);
+                                                                               that.trigger("saveSuccess");
+                                                                             }});
+                this.dirty = false;
+
+                if (!this.synchronous) {
+                    this.afterSave();
+                }
+            },
+
+            deleteClicked: function(e) {
+                e.preventDefault();
+                this.app.deleteDialog(this.model, "list");
+            },
+
+            tabClick: function(tabId, regionName) {
+                    region = this.app[regionName];
+                    if (this.currentTabRegion != undefined) {
+                        this.currentTabRegion.$el.hide();
+                    }
+                    if (this.currentTabId != undefined) {
+                        $(this.currentTabId).removeClass('active');
+                    }
+                    this.currentTabRegion = region;
+                    this.currentTabRegion.$el.show();
+
+                    this.currentTabId = tabId;
+                    $(tabId).addClass('active');
+            },
+
+            showTabs: function(tabs) {
+                template = templateFromId("#xos-tabs-template", {tabs: tabs});
+                $("#tabs").html(template(tabs));
+                var that = this;
+
+                _.each(tabs, function(tab) {
+                    var regionName = tab["region"];
+                    var tabId = '#xos-nav-'+regionName;
+                    $(tabId).bind('click', function() { that.tabClick(tabId, regionName); });
+                });
+
+                $("#tabs").show();
+            },
+
+            showLinkedItems: function() {
+                    tabs=[];
+
+                    tabs.push({name: "details", region: "detail"});
+
+                    makeFilter = function(relatedField, relatedId) {
+                        return function(model) { return model.attributes[relatedField] == relatedId; }
+                    };
+
+                    var index=0;
+                    for (relatedName in this.model.collection.relatedCollections) {
+                        var relatedField = this.model.collection.relatedCollections[relatedName];
+                        var relatedId = this.model.id;
+                        regionName = "linkedObjs" + (index+1);
+
+                        relatedListViewClassName = relatedName + "ListView";
+                        assert(this.app[relatedListViewClassName] != undefined, relatedListViewClassName + " not found");
+                        relatedListViewClass = this.app[relatedListViewClassName].extend({collection: xos[relatedName],
+                                                                                          filter: makeFilter(relatedField, relatedId),
+                                                                                          parentModel: this.model});
+                        this.app[regionName].show(new relatedListViewClass());
+                        if (this.app.hideTabsByDefault) {
+                            this.app[regionName].$el.hide();
+                        }
+                        tabs.push({name: relatedName, region: regionName});
+                        index = index + 1;
+                    }
+
+                    while (index<4) {
+                        this.app["linkedObjs" + (index+1)].empty();
+                        index = index + 1;
+                    }
+
+                    this.showTabs(tabs);
+                    this.tabClick('#xos-nav-detail', 'detail');
+              },
+
+            onFormDataInvalid: function(errors) {
+                var self=this;
+                var markErrors = function(value, key) {
+                    var $inputElement = self.$el.find("[name='" + key + "']");
+                    var $inputContainer = $inputElement.parent();
+                    //$inputContainer.find(".help-inline").remove();
+                    var $errorEl = $("<span>", {class: "help-inline error", text: value});
+                    $inputContainer.append($errorEl).addClass("error");
+                }
+                _.each(errors, markErrors);
+            },
+
+             templateHelpers: function() { return { modelName: this.model.modelName,
+                                                    collectionName: this.model.collectionName,
+                                                    addFields: this.model.addFields,
+                                                    listFields: this.model.listFields,
+                                                    detailFields: this.options.detailFields || this.detailFields || this.model.detailFields,
+                                                    fieldDisplayNames: this.options.fieldDisplayNames || this.fieldDisplayNames || this.model.fieldDisplayNames || {},
+                                                    foreignFields: this.model.foreignFields,
+                                                    detailLinkFields: this.model.detailLinkFields,
+                                                    inputType: this.model.inputType,
+                                                    model: this.model,
+                                                    detailView: this,
+                                                    choices: this.options.choices || this.choices || this.model.choices || {},
+                                                    helpText: this.options.helpText || this.helpText || this.model.helpText || {},
+                                         }},
+});
+
+XOSDetailView_sliver = XOSDetailView.extend( {
+    events: $.extend(XOSDetailView.events,
+        {"change #field_deploymentNetwork": "onDeploymentNetworkChange"}
+    ),
+
+    onShow: function() {
+        // Note that this causes the selects to be updated a second time. The
+        // first time was when the template was originally invoked, and the
+        // selects will all have the full unfiltered set of candidates. Then
+        // onShow will fire, and we'll update them with the filtered values.
+        this.onDeploymentNetworkChange();
+    },
+
+    onDeploymentNetworkChange: function(e) {
+        var deploymentID = this.$el.find("#field_deploymentNetwork").val();
+
+        console.log("onDeploymentNetworkChange");
+        console.log(deploymentID);
+
+        filterFunc = function(model) { return (model.attributes.deployment==deploymentID); }
+        newSelect = idToSelect("node",
+                               this.model.attributes.node,
+                               this.model.foreignFields["node"],
+                               "humanReadableName",
+                               false,
+                               filterFunc);
+        this.$el.find("#field_node").html(newSelect);
+
+        filterFunc = function(model) { for (index in model.attributes.deployments) {
+                                          item=model.attributes.deployments[index];
+                                          if (item.toString()==deploymentID.toString()) return true;
+                                        };
+                                        return false;
+                                     }
+        newSelect = idToSelect("flavor",
+                               this.model.attributes.flavor,
+                               this.model.foreignFields["flavor"],
+                               "humanReadableName",
+                               false,
+                               filterFunc);
+        this.$el.find("#field_flavor").html(newSelect);
+
+        filterFunc = function(model) { for (index in xos.imageDeployments.models) {
+                                           imageDeployment = xos.imageDeployments.models[index];
+                                           if ((imageDeployment.attributes.deployment == deploymentID) && (imageDeployment.attributes.image == model.id)) {
+                                               return true;
+                                           }
+                                       }
+                                       return false;
+                                     };
+        newSelect = idToSelect("image",
+                               this.model.attributes.image,
+                               this.model.foreignFields["image"],
+                               "humanReadableName",
+                               false,
+                               filterFunc);
+        this.$el.find("#field_image").html(newSelect);
     },
 });
 
-/* Give an id, the name of a collection, and the name of a field for models
-   within that collection, lookup the id and return the value of the field.
+/* XOSItemView
+      This is for items that will be displayed as table rows.
+      extend with:
+         app - MarionetteApplication
+         template - template (See XOSHelper.html)
 */
 
+XOSItemView = Marionette.ItemView.extend({
+             tagName: 'tr',
+             className: 'test-tablerow',
+
+             templateHelpers: function() { return { modelName: this.model.modelName,
+                                                    collectionName: this.model.collectionName,
+                                                    listFields: this.model.listFields,
+                                                    addFields: this.model.addFields,
+                                                    detailFields: this.model.detailFields,
+                                                    foreignFields: this.model.foreignFields,
+                                                    detailLinkFields: this.model.detailLinkFields,
+                                                    inputType: this.model.inputType,
+                                                    model: this.model,
+                                         }},
+});
+
+/* XOSListView:
+      extend with:
+         app - MarionetteApplication
+         childView - class of ItemView, probably an XOSItemView
+         template - template (see xosHelper.html)
+         collection - collection that holds these objects
+         title - title to display in template
+*/
+
+XOSListView = FilteredCompositeView.extend({
+             childViewContainer: 'tbody',
+             parentModel: null,
+
+             events: {"click button.btn-xos-add": "addClicked",
+                      "click button.btn-xos-refresh": "refreshClicked",
+                     },
+
+             _fetchStateChange: function() {
+                 if (this.collection.fetching) {
+                    $("#xos-list-title-spinner").show();
+                 } else {
+                    $("#xos-list-title-spinner").hide();
+                 }
+             },
+
+             addClicked: function(e) {
+                e.preventDefault();
+                this.app.Router.navigate("add" + firstCharUpper(this.collection.modelName), {trigger: true});
+             },
+
+             refreshClicked: function(e) {
+                 e.preventDefault();
+                 this.collection.refresh(refreshRelated=true);
+             },
+
+             initialize: function() {
+                 this.listenTo(this.collection, 'change', this._renderChildren)
+                 this.listenTo(this.collection, 'sort', function() { console.log("sort"); })
+                 this.listenTo(this.collection, 'add', function() { console.log("add"); })
+                 this.listenTo(this.collection, 'fetchStateChange', this._fetchStateChange);
+
+                 // Because many of the templates use idToName(), we need to
+                 // listen to the collections that hold the names for the ids
+                 // that we want to display.
+                 for (i in this.collection.foreignCollections) {
+                     foreignName = this.collection.foreignCollections[i];
+                     if (xos[foreignName] == undefined) {
+                         console.log("Failed to find xos class " + foreignName);
+                     }
+                     this.listenTo(xos[foreignName], 'change', this._renderChildren);
+                     this.listenTo(xos[foreignName], 'sort', this._renderChildren);
+                 }
+             },
+
+             getAddChildHash: function() {
+                if (this.parentModel) {
+                    parentFieldName = this.parentModel.relatedCollections[this.collection.collectionName];
+                    parentFieldName = parentFieldName || "unknown";
+
+                    /*parentFieldName = "unknown";
+
+                    for (fieldName in this.collection.foreignFields) {
+                        cname = this.collection.foreignFields[fieldName];
+                        if (cname = this.collection.collectionName) {
+                            parentFieldName = fieldName;
+                        }
+                    }*/
+                    return "#addChild" + firstCharUpper(this.collection.modelName) + "/" + this.parentModel.modelName + "/" + parentFieldName + "/" + this.parentModel.id; // modelName, fieldName, id
+                } else {
+                    return null;
+                }
+             },
+
+             templateHelpers: function() {
+                return { title: this.title,
+                         addChildHash: this.getAddChildHash(),
+                         foreignFields: this.collection.foreignFields,
+                         listFields: this.collection.listFields,
+                         detailLinkFields: this.collection.detailLinkFields, };
+             },
+});
+
+XOSDataTableView = Marionette.View.extend( {
+    el: '<div style="overflow: hidden">' +
+        '<h3 class="xos-list-title title_placeholder"></h3>' +
+        '<div class="header_placeholder"></div>' +
+        '<table></table>' +
+        '<div class="footer_placeholder"></div>' +
+        '</div>',
+
+    filter: undefined,
+
+     events: {"click button.btn-xos-add": "addClicked",
+              "click button.btn-xos-refresh": "refreshClicked",
+             },
+
+     _fetchStateChange: function() {
+         if (this.collection.fetching) {
+            $("#xos-list-title-spinner").show();
+         } else {
+            $("#xos-list-title-spinner").hide();
+         }
+     },
+
+     addClicked: function(e) {
+        e.preventDefault();
+        this.app.Router.navigate("add" + firstCharUpper(this.collection.modelName), {trigger: true});
+     },
+
+     refreshClicked: function(e) {
+         e.preventDefault();
+         this.collection.refresh(refreshRelated=true);
+     },
+
+
+    initialize: function() {
+        $(this.el).find(".footer_placeholder").html( xosListFooterTemplate({addChildHash: this.getAddChildHash()}) );
+        $(this.el).find(".header_placeholder").html( xosListHeaderTemplate() );
+
+        this.listenTo(this.collection, 'fetchStateChange', this._fetchStateChange);
+    },
+
+    render: function() {
+        var view = this;
+        var fieldDisplayNames = view.options.fieldDisplayNames || view.fieldDisplayNames || {};
+
+        view.columnsByIndex = [];
+        view.columnsByFieldName = {};
+        _.each(this.collection.listFields, function(fieldName) {
+            inputType = view.options.inputType || view.inputType || {};
+            mRender = undefined;
+            mSearchText = undefined;
+            sTitle = fieldName in fieldDisplayNames ? fieldDisplayNames[fieldName] : fieldNameToHumanReadable(fieldName);
+            bSortable = true;
+            if (fieldName=="backend_status") {
+                mRender = function(x,y,z) { return xosBackendStatusIconTemplate(z); };
+                sTitle = "";
+                bSortable = false;
+            } else if (fieldName in view.collection.foreignFields) {
+                var foreignCollection = view.collection.foreignFields[fieldName];
+                mSearchText = function(x) { return idToName(x, foreignCollection, "humanReadableName"); };
+            } else if (inputType[fieldName] == "spinner") {
+                mRender = function(x,y,z) { return xosDataTableSpinnerTemplate( {value: x, collectionName: view.collection.collectionName, fieldName: fieldName, id: z.id, app: view.app} ); };
+            }
+            if ($.inArray(fieldName, view.collection.detailLinkFields)>=0) {
+                var collectionName = view.collection.collectionName;
+                mRender = function(x,y,z) { return '<a href="#' + collectionName + '/' + z.id + '">' + x + '</a>'; };
+            }
+            thisColumn = {sTitle: sTitle, bSortable: bSortable, mData: fieldName, mRender: mRender, mSearchText: mSearchText};
+            view.columnsByIndex.push( thisColumn );
+            view.columnsByFieldName[fieldName] = thisColumn;
+        });
+
+        if (!view.noDeleteColumn) {
+            deleteColumn = {sTitle: "", bSortable: false, mRender: function(x,y,z) { return xosDeleteButtonTemplate({modelName: view.collection.modelName, id: z.id}); }, mData: function() { return "delete"; }};
+            view.columnsByIndex.push(deleteColumn);
+            view.columnsByFieldName["delete"] = deleteColumn;
+        };
+
+        oTable = $(this.el).find("table").dataTable( {
+            "bJQueryUI": true,
+            "bStateSave": true,
+            "bServerSide": true,
+            "bFilter": ! (view.options.disableFilter || view.disableFilter),
+            "bPaginate": ! (view.options.disablePaginate || view.disablePaginate),
+            "aoColumns": view.columnsByIndex,
+
+            fnServerData: function(sSource, aoData, fnCallback, settings) {
+                var compareColumns = function(sortCols, sortDirs, a, b) {
+                    a = a[sortCols[0]];
+                    b = b[sortCols[0]];
+                    result = (a==b) ? 0 : ((a<b) ? -1 : 1);
+                    if (sortDirs[0] == "desc") {
+                        result = -result;
+                    }
+                    return result;
+                };
+
+                var searchMatch = function(row, sSearch) {
+                    for (fieldName in row) {
+                        if (fieldName in view.columnsByFieldName) {
+                            try {
+                                value = row[fieldName].toString();
+                            } catch(e) {
+                                continue;
+                            }
+                            if (value.indexOf(sSearch) >= 0) {
+                                return true;
+                            }
+                        }
+                    }
+                    return false;
+                };
+
+                //console.log(aoData);
+\r
+                // function used to populate the DataTable with the current\r
+                // content of the collection\r
+                var populateTable = function()\r
+                {\r
+                  //console.log("populatetable!");\r
+\r
+                  // clear out old row views\r
+                  rows = [];\r
+\r
+                  sSearch = null;\r
+                  iDisplayStart = 0;\r
+                  iDisplayLength = 1000;\r
+                  sortDirs = [];\r
+                  sortCols = [];\r
+                  _.each(aoData, function(param) {\r
+                      if (param.name == "sSortDir_0") {\r
+                          sortDirs = [param.value];\r
+                      } else if (param.name == "iSortCol_0") {\r
+                          sortCols = [view.columnsByIndex[param.value].mData];\r
+                      } else if (param.name == "iDisplayStart") {\r
+                          iDisplayStart = param.value;\r
+                      } else if (param.name == "iDisplayLength") {\r
+                          iDisplayLength = param.value;\r
+                      } else if (param.name == "sSearch") {\r
+                          sSearch = param.value;\r
+                      }\r
+                  });\r
+\r
+                  aaData = view.collection.toJSON();\r
+\r
+                  // apply backbone filtering on the models\r
+                  if (view.filter) {\r
+                      aaData = aaData.filter( function(row) { model = {}; model.attributes = row; return view.filter(model); } );\r
+                  }\r
+\r
+                  var totalSize = aaData.length;\r
+\r
+                  // turn the ForeignKey fields into human readable things\r
+                  for (rowIndex in aaData) {\r
+                      row = aaData[rowIndex];\r
+                      for (fieldName in row) {\r
+                          if (fieldName in view.columnsByFieldName) {\r
+                              mSearchText = view.columnsByFieldName[fieldName].mSearchText;\r
+                              if (mSearchText) {\r
+                                  row[fieldName] = mSearchText(row[fieldName]);\r
+                              }\r
+                          }\r
+                      }\r
+                  }\r
+\r
+                  // apply datatables search\r
+                  if (sSearch) {\r
+                      aaData = aaData.filter( function(row) { return searchMatch(row, sSearch); });\r
+                  }\r
+\r
+                  var filteredSize = aaData.length;\r
+\r
+                  // apply datatables sort\r
+                  aaData.sort(function(a,b) { return compareColumns(sortCols, sortDirs, a, b); });\r
+\r
+                  // slice it for pagination\r
+                  if (iDisplayLength >= 0) {\r
+                      aaData = aaData.slice(iDisplayStart, iDisplayStart+iDisplayLength);\r
+                  }\r
+\r
+                  return fnCallback({iTotalRecords: totalSize,\r
+                         iTotalDisplayRecords: filteredSize,\r
+                         aaData: aaData});\r
+                };\r
+\r
+                aoData.shift(); // ignore sEcho
+                populateTable();
+
+                view.listenTo(view.collection, 'change', populateTable);
+                view.listenTo(view.collection, 'add', populateTable);
+                view.listenTo(view.collection, 'remove', populateTable);
+            },
+        } );
+
+        return this;
+    },
+
+     getAddChildHash: function() {
+        if (this.parentModel) {
+            parentFieldName = this.parentModel.relatedCollections[this.collection.collectionName];
+            parentFieldName = parentFieldName || "unknown";
+
+            /*parentFieldName = "unknown";
+
+            for (fieldName in this.collection.foreignFields) {
+                cname = this.collection.foreignFields[fieldName];
+                if (cname = this.collection.collectionName) {
+                    parentFieldName = fieldName;
+                }
+            }*/
+            return "#addChild" + firstCharUpper(this.collection.modelName) + "/" + this.parentModel.modelName + "/" + parentFieldName + "/" + this.parentModel.id; // modelName, fieldName, id
+        } else {
+            return null;
+        }
+     },
+
+});
+
 idToName = function(id, collectionName, fieldName) {
-    linkedObject = xos[collectionName].get(id);
-    if (linkedObject == undefined) {
-        return "#" + id;
-    } else {
-        return linkedObject.attributes[fieldName];
-    }
+    return xos.idToName(id, collectionName, fieldName);
+};
+
+makeIdToName = function(collectionName, fieldName) {
+    return function(id) { return idToName(id, collectionName, fieldName); }
 };
 
 /* Constructs lists of <option> html blocks for items in a collection.
@@ -48,7 +1073,7 @@ idToName = function(id, collectionName, fieldName) {
    fieldName = name of field within models of collection that will be displayed
 */
 
-idToOptions = function(selectedId, collectionName, fieldName) {
+idToOptions = function(selectedId, collectionName, fieldName, filterFunc) {
     result=""
     for (index in xos[collectionName].models) {
         linkedObject = xos[collectionName].models[index];
@@ -59,6 +1084,9 @@ idToOptions = function(selectedId, collectionName, fieldName) {
         } else {
             selected = "";
         }
+        if ((filterFunc) && (!filterFunc(linkedObject))) {
+            continue;
+        }
         result = result + '<option value="' + linkedId + '"' + selected + '>' + linkedName + '</option>';
     }
     return result;
@@ -72,10 +1100,37 @@ idToOptions = function(selectedId, collectionName, fieldName) {
    fieldName = name of field within models of collection that will be displayed
 */
 
-idToSelect = function(variable, selectedId, collectionName, fieldName) {
-    result = '<select name="' + variable + '">' +
-             idToOptions(selectedId, collectionName, fieldName) +
+idToSelect = function(variable, selectedId, collectionName, fieldName, readOnly, filterFunc) {
+    if (readOnly) {
+        readOnly = " readonly";
+    } else {
+        readOnly = "";
+    }
+    result = '<select name="' + variable + '" id="field_' + variable + '"' + readOnly + '>' +
+             idToOptions(selectedId, collectionName, fieldName, filterFunc) +
              '</select>';
     return result;
 }
 
+choicesToOptions = function(selectedValue, choices) {
+    result="";
+    for (index in choices) {
+        choice = choices[index];
+        displayName = choice[0];
+        value = choice[1];
+        if (value == selectedValue) {
+            selected = " selected";
+        } else {
+            selected = "";
+        }
+        result = result + '<option value="' + value + '"' + selected + '>' + displayName + '</option>';
+    }
+    return result;
+}
+
+choicesToSelect = function(variable, selectedValue, choices) {
+    result = '<select name="' + variable + '" id="field_' + variable + '">' +
+             choicesToOptions(selectedValue, choices) +
+             '</select>';
+    return result;
+}