define(['underscore',
'jquery',
'backbone',
'models/portals/PortalModel',
"models/portals/PortalImage",
"collections/Filters",
'views/EditorView',
"views/SignInView",
"views/portals/editor/PortEditorSectionsView",
"views/portals/editor/PortEditorImageView",
"text!templates/loading.html",
"text!templates/portals/editor/portalEditor.html",
"text!templates/portals/editor/portalEditorSubmitMessage.html"
],
function(_, $, Backbone, Portal, PortalImage, Filters, EditorView, SignInView,
PortEditorSectionsView, ImageEdit, LoadingTemplate, Template,
portalEditorSubmitMessageTemplate){
/**
* @class PortalEditorView
* @classdesc A view of a form for creating and editing DataONE Portal documents
* @name PortalEditorView
* @extends EditorView
* @constructs
*/
var PortalEditorView = EditorView.extend(
/** @lends PortalEditorView.prototype */{
/**
* The type of View this is
* @type {string}
*/
type: "PortalEditor",
/**
* The short name OR pid for the portal
* @type {string}
*/
portalIdentifier: "",
/**
* The PortalModel that is being edited
* @type {Portal}
*/
model: undefined,
/**
* The currently active editor section. e.g. Data, Metrics, Settings, etc.
* @type {string}
*/
activeSectionLabel: "",
/**
* When a new portal is being created, this is the label of the section that will be active when the editor first renders
* @type {string}
*/
newPortalDefaultSectionLabel: "Settings",
/**
* References to templates for this view. HTML files are converted to Underscore.js templates
*/
template: _.template(Template),
loadingTemplate: _.template(LoadingTemplate),
// Over-ride the default editor submit message template (which is currently
// used by the metadata editor) with the portal editor version
editorSubmitMessageTemplate: _.template(portalEditorSubmitMessageTemplate),
/**
* An array of Backbone Views that are contained in this view.
* @type {Backbone.View[]}
*/
subviews: [],
/**
* A reference to the PortEditorSectionsView for this instance of the PortEditorView
* @type {PortEditorSectionsView}
*/
sectionsView: null,
/**
* The text to use in the editor submit button
* @type {string}
*/
submitButtonText: "Save",
/**
* A jQuery selector for the element that the PortEditorSectionsView should be inserted into
* @type {string}
*/
portEditSectionsContainer: ".port-editor-sections-container",
/**
* A jQuery selector for the element that the portal logo image uploader
* should be inserted into
* @type {string}
*/
portEditLogoContainer: ".logo-editor-container",
/**
* A jQuery selector for links to view this portal
* @type {string}
*/
viewPortalLinks: ".view-portal-link",
/**
* A temporary name to use for portals when they are first created but don't have a label yet.
* This name should only be used in views, and never set on the model so it doesn't risk getting
* serialized and saved.
* @type {string}
*/
newPortalTempName: "new",
/**
* The events this view will listen to and the associated function to call.
* This view will inherit events from the parent class, EditorView.
* @type {Object}
*/
events: _.extend(EditorView.prototype.events, {
"focusout .basic-text" : "updateBasicText",
"click .section-links-toggle-container" : "toggleSectionLinks"
}),
/**
* Is executed when a new PortalEditorView is created
* @param {Object} options - A literal object with options to pass to the view
*/
initialize: function(options){
//Reset arrays and objects set on this View, otherwise they will be shared across intances, causing errors
this.subviews = new Array();
this.sectionsView = null;
if(typeof options == "object"){
// initializing the PortalEditorView properties
this.portalIdentifier = options.portalIdentifier ? options.portalIdentifier : undefined;
this.activeSectionLabel = options.activeSectionLabel || "";
}
},
/**
* Renders the PortalEditorView
*/
render: function(){
//Execute the superclass render() function, which will add some basic Editor functionality
EditorView.prototype.render.call(this);
$("body").addClass("Portal");
// Display a spinner to indicate loading until model is created.
this.$el.html(this.loadingTemplate({
msg: "Retrieving portal details..."
}));
//Create the model
this.createModel();
// An exisiting portal should have a portalIdentifier already set
// from the router, that does not equal the newPortalTempName ("new"),
// plus a seriesId or label set during createModel()
if (
(this.model.get("seriesId") || this.model.get("label"))
&&
(this.portalIdentifier && this.portalIdentifier != this.newPortalTempName)
){
var view = this;
this.listenToOnce(this.model, "change:isAuthorized", function(){
if (this.model.get("isAuthorized")) {
// When an existing model has been synced render the results
view.stopListening(view.model, "sync", view.renderPortalEditor);
view.listenToOnce(view.model, "sync", view.renderPortalEditor);
// If the portal model already exists - fetch it.
view.model.fetch();
// Listens to the focus event on the window to detect when a user
// switches back to this browser tab from somewhere else
// When a user checks back, we want to check for log-in status
MetacatUI.appView.listenForActivity();
// Determine the length of time until the user's current token expires
// Asks to sign in in case of time out
MetacatUI.appView.listenForTimeout();
}
else {
// generate error message
var msg = MetacatUI.appModel.get("portalEditNotAuthEditMessage");
//Show the not authorized error message
MetacatUI.appView.showAlert(msg, "alert-error non-fixed", this.$el);
}
});
// Check if the user is Authorized to edit the portal
this.authorizeUser();
}
//If there is no portal identifier given, this is a new portal.
else {
// if the user is not signed in, display the sign in view
if ( MetacatUI.appUserModel.get("tokenChecked") && !MetacatUI.appUserModel.get("loggedIn")) {
this.showSignIn();
}
else{
//Check the user's quota to create a new Portal
this.listenToOnce(MetacatUI.appUserModel, "change:isAuthorizedCreatePortal", function(){
if( MetacatUI.appUserModel.get("isAuthorizedCreatePortal") ){
// Start new portals on the settings tab
this.activeSectionLabel = this.newPortalDefaultSectionLabel;
// Render the default model if the portal is new
this.renderPortalEditor();
}
else{
//If the user doesn't have quota left, display this message
if( MetacatUI.appUserModel.get("portalQuota") == 0 ){
var errorMessage = MetacatUI.appModel.get("portalEditNoQuotaMessage");
}
//Otherwise, display a more generic error message
else{
var errorMessage = MetacatUI.appModel.get("portalEditNotAuthCreateMessage");
}
//Hide the loading icon
this.hideLoading();
//Show the error message
MetacatUI.appView.showAlert(errorMessage, "alert-error non-fixed", this.$el);
}
//Reset the isAuthorizedCreatePortal attribute
MetacatUI.appUserModel.set("isAuthorizedCreatePortal", null);
});
//If the user authentication hasn't been checked yet, then wait for it
if ( !MetacatUI.appUserModel.get("tokenChecked") ) {
this.listenTo(MetacatUI.appUserModel, "change:tokenChecked", function(){
MetacatUI.appUserModel.isAuthorizedCreatePortal();
});
return;
}
//If the user is logged in,
else if( MetacatUI.appUserModel.get("loggedIn") ){
//Check if this user is authorized to create a new portal
MetacatUI.appUserModel.isAuthorizedCreatePortal();
}
//If the user is not logged in, show the sign in buttons
else if( !MetacatUI.appUserModel.get("loggedIn") ){
this.showSignIn();
}
}
}
return this;
},
/**
* Renders the portal editor view once the portal view is created
*/
renderPortalEditor: function() {
// Add the template to the view and give the body the "Editor" class
this.$el.html(this.template({
name: this.model.get("name"),
submitButtonText: this.submitButtonText,
primaryColor: this.model.get("primaryColor"),
secondaryColor: this.model.get("secondaryColor"),
accentColor: this.model.get("accentColor"),
primaryColorTransparent: this.model.get("primaryColorTransparent"),
secondaryColorTransparent: this.model.get("secondaryColorTransparent"),
accentColorTransparent: this.model.get("accentColorTransparent")
}));
//Render the editor controls
this.renderEditorControls();
//Remove the rendering class from the body element
$("body").removeClass("rendering");
// Auto-resize the height of the portal title field on user-input and on
// window resize events.
$( window ).resize(function() {
$("textarea.portal-title").trigger("windowResize");
});
this.$("textarea.portal-title").each(function () {
this.style.height = '0px'; // note: textfield MUST have a min-height set
this.style.height = (this.scrollHeight) + 'px';
}).on('input windowResize', function () {
this.style.height = '0px'; // note: textfield MUST have a min-height set
this.style.height = (this.scrollHeight) + 'px';
});
// Get the portal identifier
// or set it to a default value in the case that it's a new portal
var portalIdentifier = this.portalIdentifier;
if(!portalIdentifier){
portalIdentifier = this.newPortalTempName;
}
//Create a view for the editor sections
this.sectionsView = new PortEditorSectionsView({
model: this.model,
activeSectionLabel: this.activeSectionLabel,
newPortalTempName: this.newPortalTempName
});
//Save the PortEditorSectionsView as a subview
this.subviews.push(this.sectionsView);
//Attach a reference to this view
this.sectionsView.editorView = this;
//Add the view element to this view
this.$(this.portEditSectionsContainer).html(this.sectionsView.el);
//Render the sections view
this.sectionsView.render();
//Show the required fields for this editor
this.renderRequiredIcons(MetacatUI.appModel.get("portalEditorRequiredFields"));
// Insert the logo editor
this.renderLogoEditor();
// On mobile, hide section tabs a moment after page loads so
// users notice where they are
var view= this;
setTimeout(function () {
view.toggleSectionLinks();
}, 700);
// On mobile where the section-links-toggle-container is set to fixed,
// hide the portal navigation element when user scrolls down,
// show again when the user scrolls up.
MetacatUI.appView.prevScrollpos = window.pageYOffset;
$(window).on("scroll", "", undefined, this.handleScroll);
//Show a link to view the portal, if it is not a new portal
if( !this.model.isNew() ){
var viewURL = MetacatUI.root + "/" + MetacatUI.appModel.get("portalTermPlural") +"/" + portalIdentifier;
//Update the view URL in any other portal view links
this.$(this.viewPortalLinks).attr("href", viewURL).show();
}
else{
//Remove the href attribute and hide the link
this.$(this.viewPortalLinks).attr("href", "").hide();
}
},
/**
* Create a PortalModel object
*/
createModel: function(){
// Look up the portal document seriesId by its registered name if given
if ( this.portalIdentifier && this.portalIdentifier != this.newPortalTempName) {
// Create a new portal model with the identifier
this.model = new Portal({
label: this.portalIdentifier
});
// Save the original label in case a user changes it. During URL
// validation, the original label will always be shown as available.
// TODO: if user navigates to portal using a SID or PID, we will need
// to get the matching label and then save it to the model
this.model.set("originalLabel", this.portalIdentifier);
// Otherwise, create a new portal
} else {
// Create a new, default portal model
this.model = new Portal({
//Set the isNew attribute so the model will execute certain functions when a Portal is new
isNew: true
});
}
// set listeners on the new model
this.setListeners();
},
/**
* The authorizeUser function checks if the current user is authorized
* to edit the given PortalModel. If not, a message is displayed and
* the view doesn't render anything else.
*
* If the user isn't logged in at all, don't check for authorization and
* display a message and login button.
*/
authorizeUser: function() {
//If the user authentication hasn't been checked yet, wait for it to finish.
if( !MetacatUI.appUserModel.get("tokenChecked") ){
this.listenToOnce(MetacatUI.appUserModel, "change:tokenChecked", this.authorizeUser);
return;
}
//If the user authentication has been checked and they are not logged in, then display the Sign In buttons
else if ( MetacatUI.appUserModel.get("tokenChecked") && !MetacatUI.appUserModel.get("loggedIn") ){
//Remove the loading message
this.hideLoading();
// show the sign in view
this.showSignIn();
return;
}
else{
//If the seriesId hasn't been found yet, but we have the label
if( !this.model.get("seriesId") && !this.model.get("latestVersion") && this.model.get("label") ){
//When the seriesId or latest pid is found, come back to this function
this.listenToOnce(this.model, "change:seriesId", this.authorizeUser);
this.listenToOnce(this.model, "latestVersionFound", this.authorizeUser);
//If the portal isn't found, display a 404 message
this.listenToOnce(this.model, "notFound", this.showNotFound);
//Get the seriesId or latest pid
this.model.getSeriesIdByName();
return;
}
else{
//Remove the listeners for the seriesId and latest pid
this.stopListening(this.model, "change:seriesId", this.authorizeUser);
this.stopListening(this.model, "latestVersionFound", this.authorizeUser);
}
// checking for the write Permission
this.model.checkAuthority("write");
}
},
/**
* Hides the loading
*/
hideLoading: function() {
// Find the loading object and remove it.
if (this.$el.find(".loading")) {
this.$el.find(".loading").remove();
}
},
/**
* toggleSectionLinks - show or hide the section links. Used for the
* mobile/small screen view of the portal.
*/
toggleSectionLinks: function(e){
try{
// Don't close the menu if the user clicked the dropdown for the rename/delete menu.
if(e && e.target){
if( $(e.target).closest(".section-menu-link") || $(e.target).closest(".dropdown-menu")){
return
}
}
// Only toggle the section links on mobile. On mobile, the
// ".show-sections-toggle" is visible.
if(this.$(".show-sections-toggle").is(":visible")){
this.$("#portal-section-tabs").slideToggle();
}
} catch(e){
console.error("Failed to toggle section links, error message: " + e);
}
},
/**
* renderLogoEditor - Creates a new PortalImage model for the portal logo if
* one doesn't exist already, then inserts an ImageEdit view into the
* portEditLogoContainer.
*/
renderLogoEditor: function() {
try {
// If the portal has no logo, add the default model for one
if(!this.model.get("logo")){
this.model.set("logo", new PortalImage({
label: "logo",
nodeName: "logo"
})
);
};
// Add the image view (incl. uploader) for the portal logo
this.logoEdit = new ImageEdit({
model: this.model.get("logo"),
imageUploadInstructions: "Drag & drop a logo here or click to upload",
imageWidth: 100,
imageHeight: 100,
minWidth: 64,
minHeight: 64,
maxHeight: 300,
maxWidth: 300,
nameLabel: false,
urlLabel: false,
imageTagName: "img",
removeButton: false
});
this.$(this.portEditLogoContainer).append(this.logoEdit.el);
this.logoEdit.render();
this.logoEdit.editorView = this;
} catch (e) {
console.error("Logo editor view could not be rendered. Error message: " + e);
}
},
/**
* When a simple text input field loses focus, the corresponding model
* attribute is updated with the value from the input field
*
* @param {Event} [e] - The focusout event
*/
updateBasicText: function(e){
if(!e) return false;
//Get the category, new value, and model
var category = $(e.target).attr("data-category"),
value = $(e.target).val(),
model = $(e.target).data("model") || this.model;
//We can't update anything without a category
if(!category) return false;
//Clean up the value string so it's valid for XML
value = this.model.cleanXMLText(value);
//If the value is an empty string,
if( typeof value == "string" && !value.length ){
//Remove the value from the input
$(e.target).val("");
}
//If the value is only spaces,
else if( typeof value == "string" && !value.trim().length ){
//Remove the value from the input
$(e.target).val("");
//Update the model as if this is an empty string
value = "";
}
//Get the current value
var currentValue = model.get(category);
//Insert the new value into the array
if( Array.isArray(currentValue) ){
//Find the position this text input is in
var position = $(e.target)
.parents("div.text-container")
.first()
.children("div")
.index( $(e.target).parent() );
//Set the value in that position in the array
currentValue[position] = value;
//Set the changed array on this model
model.set(category, currentValue);
model.trigger("change:" + category);
}
//Update the model if the current value is a string
else if(typeof currentValue == "string" || !currentValue){
model.set(category, value);
model.trigger("change:" + category);
}
//TODO: Add another blank text input (write addBasicText function)
// if($(e.target).is(".new") && value != '' && category != "title"){
// $(e.target).removeClass("new");
// this.addBasicText(e);
// }
},
/**
* When the object is saved successfully, tell the user.
* @param {object} savedObject - the object that was successfully saved
*/
saveSuccess: function(savedObject){
var identifier = this.model.get("label") || this.model.get("seriesId") || this.model.get("id"),
viewURL = MetacatUI.root + "/"+ MetacatUI.appModel.get("portalTermPlural") +"/" + identifier;
var message = this.editorSubmitMessageTemplate({
messageText: "Your changes have been submitted.",
viewURL: viewURL,
buttonText: "View your " + MetacatUI.appModel.get("portalTermSingular")
});
MetacatUI.appView.showAlert(message, "alert-success", this.$el, null, {remove: true});
//Update the view URL in any other portal view links
this.$(this.viewPortalLinks).attr("href", viewURL).show();
this.hideSaving();
this.removeValidation();
// Update the path in case the user selected a new portal label
this.sectionsView.updatePath();
// Reset the original label (note: this MUST occur AFTER updatePath())
this.model.set("originalLabel", this.model.get("label"));
},
/**
* When the Portal model has been flagged as invalid, show the validation error messages
*/
showValidation: function(){
//First clear all the error messaging
this.removeValidation();
var errors = this.model.validationError;
_.each(errors, function(errorMsg, category){
var categoryEls = this.$("[data-category='" + category + "']");
//The label category is unique, because it is duplicated in the PortalImage, which can cause bugs
if( category == "label" ){
categoryEls = this.$(".change-label-container [data-category='label']");
}
//Get the elements that have views attached to them
var elsWithViews = _.filter(categoryEls, function(el){
return ( $(el).data("view") &&
$(el).data("view").showValidation &&
!$(el).data("view").isNew );
});
//If at least one element of this category has a view,
if(elsWithViews.length){
//Use the view's showValidation function, if it exists.
_.each(elsWithViews, function(el){
var view = $(el).data("view");
if( view && view.showValidation ){
view.showValidation();
}
});
}
else{
//Show the validation message
this.showValidationMessage(categoryEls, errorMsg);
}
}, this);
if(errors){
MetacatUI.appView.showAlert("Fix the errors flagged below before submitting.",
"alert-error",
this.$el,
null,
{
remove: true
});
//Hide the saving styling
this.hideSaving();
}
},
/**
* Shows a validation error message and adds error styling to the given elements
* @param {jQuery} elements - The elements to add error styling and messaging to
* @param {string} errorMsg - The error message to display
*/
showValidationMessage: function(elements, errorMsg){
//Show the error message
elements.filter(".notification").addClass("error").text(errorMsg);
//Add the error class to inputs
var inputs = elements.filter("textarea, input").addClass("error");
//Show the validation message in the portal sections
if( this.sectionsView ){
this.sectionsView.showValidation(elements);
}
},
/**
* Removes all the validation error styling and messaging from this view
*/
removeValidation: function(){
EditorView.prototype.removeValidation.call(this);
this.$(".section-link-container.error, input.error, textarea.error").removeClass("error");
},
/**
* Show Sign In buttons
*/
showSignIn: function(){
var container = $(document.createElement("div")).addClass("container center");
this.$el.html(container);
var signInButtons = new SignInView().render().el;
// Message to create a portal if the portal is new
if (this.model.get("isNew")) {
$(container).append('<h1>Sign in to create a portal</h1>', signInButtons);
}
else {
$(container).append('<h1>Sign in to edit a portal</h1>', signInButtons);
}
},
/**
* If the given portal doesn't exist, display a Not Found message.
*/
showNotFound: function(){
this.hideLoading();
var notFoundMessage = "The portal \"" + (this.model.get("label") || this.portalIdentifier) +
"\" doesn't exist.";
MetacatUI.appView.showAlert(notFoundMessage, "alert-error non-fixed", this.$el, undefined, { remove: true });
},
/**
* This function is called whenever the window is scrolled.
*/
handleScroll: function() {
var menu = $(".section-links-toggle-container")[0],
menuHeight = $(menu).height(),
editorFooterHeight = 73,
hiddenHeight = (menuHeight * -1) + 73;
var currentScrollPos = window.pageYOffset;
if(MetacatUI.appView.prevScrollpos > currentScrollPos) {
menu.style.bottom = "73px";
} else {
menu.style.bottom = hiddenHeight +"px";
}
MetacatUI.appView.prevScrollpos = currentScrollPos;
},
/**
* @inheritdoc
*/
onClose: function(){
//Call the superclass onClose() function
EditorView.prototype.onClose.call(this);
//Remove the Portal class from the body element
$("body").removeClass("Portal");
//Remove the scroll listener
$(window).off("scroll", "", this.handleScroll);
//Close and remove all of the subviews
_.invoke(this.subviews, "onClose");
_.invoke(this.subviews, "remove");
//Reset the subviews array
this.subviews = new Array();
//Reset the sectionsView reference
this.sectionsView = null;
},
});
return PortalEditorView;
});