define([
"underscore",
"jquery",
"backbone",
"views/SignInView",
"text!templates/editorSubmitMessage.html",
], function (_, $, Backbone, SignInView, EditorSubmitMessageTemplate) {
/**
* @class EditorView
* @classdesc A basic shell of a view, primarily meant to be extended for views that allow editing capabilities.
* @classcategory Views
* @name EditorView
* @extends Backbone.View
* @constructs
*/
var EditorView = Backbone.View.extend(
/** @lends EditorView.prototype */ {
/**
* References to templates for this view. HTML files are converted to Underscore.js templates
*/
editorSubmitMessageTemplate: _.template(EditorSubmitMessageTemplate),
/**
* The element this view is contained in. A jQuery selector or the element itself.
* @type {string|DOMElement}
*/
el: "#Content",
/**
* The text to use in the editor submit button
* @type {string}
*/
submitButtonText: "Save",
/**
* The text to use in the editor submit button
* @type {string}
*/
accessPolicyModalID: "editor-access-policy-modal",
/**
* The selector for the HTML element that will contain a button/link/control for
* opening the AccessPolicyView modal window. If this element doesn't exist on the page,
* then the AccessPolicyView will be inserted into the `accessPolicyViewContainer` directly, rather than a modal window.
* @type {string}
*/
accessPolicyControlContainer: ".access-policy-control-container",
/**
* The selector for the HTML element that will contain the AccessPolicyView.
* If this element doesn't exist on the page, then the AccessPolicyView will not be inserted into the page.
* If a `accessPolicyControlContainer` element is on the page, then this element will
* contain the modal window element.
* @type {string}
*/
accessPolicyViewContainer: ".access-policy-view-container",
/**
* The events this view will listen to and the associated function to call
* @type {Object}
*/
events: {
"click #save-editor": "save",
"click .access-policy-control": "showAccessPolicyModal",
"keypress input:not(.ignore-changes)": "showControls",
"keypress textarea:not(.ignore-changes)": "showControls",
"keypress [contenteditable]:not(.ignore-changes)": "showControls",
"click .image-uploader": "showControls",
"change .access-policy-view": "showControls",
"click .access-policy-view .remove": "showControls",
},
/**
* Renders this view
*/
render: function () {
//Style the body as an Editor
$("body").addClass("Editor rendering");
this.delegateEvents();
//If there is no active alternate repository, set one
if (
!MetacatUI.appModel.getActiveAltRepo() &&
MetacatUI.appModel.get("alternateRepositories").length
) {
MetacatUI.appModel.setActiveAltRepo();
}
},
/**
* Set listeners on the view's model.
* This function centralizes all the listeners so that when/if the view's
* model is replaced, the listeners can be reset.
*/
setListeners: function () {
//Stop listening first
this.stopListening(this.model, "errorSaving", this.saveError);
this.stopListening(this.model, "successSaving", this.saveSuccess);
this.stopListening(this.model, "invalid", this.showValidation);
//Set listeners
this.listenTo(this.model, "errorSaving", this.saveError);
this.listenTo(this.model, "successSaving", this.saveSuccess);
this.listenTo(this.model, "invalid", this.showValidation);
// //Set a beforeunload event only if there isn't one already
// if( !this.beforeunloadCallback ){
// var view = this;
// //When the Window is about to be closed, show a confirmation message
// this.beforeunloadCallback = function(e){
// if( !view.canClose() ){
// //Browsers don't support custom confirmation messages anymore,
// // so preventDefault() needs to be called or the return value has to be set
// e.preventDefault();
// e.returnValue = "";
// }
// return;
// }
// window.addEventListener("beforeunload", this.beforeunloadCallback);
// }
},
/**
* Show Sign In buttons
*/
showSignIn: function () {
var container = $(document.createElement("div")).addClass(
"container center",
);
this.$el.html(container);
var signInButtons = new SignInView().render().el;
$(container).append("<h1>Sign in to submit data</h1>", signInButtons);
},
/**
* Saves the model
*/
save: function () {
this.showSaving();
this.model.save();
},
/**
* Cancel all edits in the editor by simply re-rendering the view
*/
cancel: function () {
this.render();
},
/**
* Trigger a save error with a message that the save was cancelled
*/
handleSaveCancel: function () {
if (this.model.get("uploadStatus") == "e") {
this.saveError("Your submission was cancelled due to an error.");
}
},
/**
* Adds top-level control elements to this editor.
*/
renderEditorControls: function () {
//If the AccessPolicy editor is enabled, add a button for opening it
if (MetacatUI.appModel.get("allowAccessPolicyChanges")) {
this.renderAccessPolicyControl();
}
},
/**
* Adds a Share button for editing the access policy
*/
renderAccessPolicyControl: function () {
//If the AccessPolicy editor is enabled, add a button for opening it
if (this.isAccessPolicyEditEnabled()) {
var isHiddenBehindControl =
this.$(this.accessPolicyControlContainer).length > 0;
//Render the AccessPolicy control, if the container element is on the page
if (isHiddenBehindControl) {
//If it isn't, then add it to the page.
//Create an anchor tag with an icon and the text "Share" and add it to the editor controls container
this.$(this.accessPolicyControlContainer).prepend(
$(document.createElement("a"))
.attr("href", "#")
.addClass("access-policy-control btn")
.append(
$(document.createElement("i")).addClass(
"icon-group icon icon-on-left",
),
"Share",
),
);
}
//If the authorization has already been checked
if (this.model.get("isAuthorized_changePermission") === true) {
//Render the AccessPolicyView
this.renderAccessPolicy();
} else {
//When the user's changePermission authority has been checked, edit their
// access to the AccessPolicyView
this.listenToOnce(
this.model,
"change:isAuthorized_changePermission",
function () {
//If there is an AccessPolicy control, disable it
if (isHiddenBehindControl) {
if (
this.model.get("isAuthorized_changePermission") === false
) {
//Disable the button for the AccessPolicyView if the user is not authorized
this.$(".access-policy-control")
.attr("disabled", "disabled")
.attr(
"title",
"You do not have access to change the " +
MetacatUI.appModel.get("accessPolicyName"),
)
.addClass("disabled");
}
} else {
//Render the AccessPolicyView
this.renderAccessPolicy();
}
},
);
//Check the user's authority to change permissions on this object
this.model.checkAuthority("changePermission");
}
}
},
/**
* Shows the AccessPolicyView for the object being edited.
*
* @param {Event} e - The click event
* @param {Backbone.Model | null} model - The model to show the view for. If
* null, defaults to the model set for the view.
*/
showAccessPolicyModal: function (e, model) {
try {
// If the AccessPolicy editor is disabled in this app, or the specific
// .access-policy-control has theh class diasbled, then exit now
if (
!MetacatUI.appModel.get("allowAccessPolicyChanges") ||
this.$(".access-policy-control").attr("disabled") == "disabled" ||
(e.currentTarget && $(e.currentTarget).hasClass("disabled"))
) {
return;
}
this.renderAccessPolicy(model);
this.on("accessPolicyViewRendered", function () {
//Add modal classes to the access policy view
this.$(".access-policy-view")
.addClass("access-policy-view-modal modal")
.css("height", window.outerHeight * 0.7)
.modal()
.modal("show");
});
} catch (e) {
console.error("Error trying to show the AccessPolicyView: ", e);
}
},
/**
* Renders the AccessPolicyView
* @param {Backbone.Model} model - Optional. The Model to render the
* AccessPolicy of. If not passed, method uses the Editor's model
*/
renderAccessPolicy: function (model) {
// Use specified model or default to the editor's model
model = model || this.model;
try {
//If the AccessPolicy editor is disabled in this app, then exit now
if (!MetacatUI.appModel.get("allowAccessPolicyChanges")) {
return;
}
var thisView = this;
require(["views/AccessPolicyView"], function (AccessPolicyView) {
// Create a new AccessPolicyView using the AccessPolicy collection
var accessPolicyView = new AccessPolicyView({
collection: model.get("accessPolicy"),
});
// Turn on accessPolicy broadcasting for metadata models
if (model.get("type") === "Metadata") {
accessPolicyView.broadcast = true;
}
//Store a reference to the AccessPolicyView on this view
thisView.accessPolicyView = accessPolicyView;
//Add the view to the page
thisView
.$(thisView.accessPolicyViewContainer)
.html(accessPolicyView.el);
//Render the AccessPolicyView
accessPolicyView.render();
thisView.trigger("accessPolicyViewRendered");
thisView.listenTo(
accessPolicyView.collection,
"add remove",
thisView.showControls,
);
});
} catch (e) {
console.error("Error trying to render the AccessPolicyView: ", e);
}
},
/**
* Checks if the Access Policy editor is enabled in this instance of MetacatUI for
* the type of object being edited.
* @returns {boolean}
* @since 2.15.0
*/
isAccessPolicyEditEnabled: function () {
if (!MetacatUI.appModel.get("allowAccessPolicyChanges")) {
return false;
}
},
/**
* Show the editor footer controls (Save bar)
*/
showControls: function () {
var view = this;
this.$(".editor-controls")
.removeClass("hidden")
.slideDown(300, function () {
if (typeof view.handleScroll === "function") {
view.handleScroll();
}
});
},
/**
* Hide the editor footer controls (Save bar)
*/
hideControls: function () {
var view = this;
this.hideSaving();
this.$(".editor-controls").slideUp(300, function () {
if (typeof view.handleScroll === "function") {
view.handleScroll();
}
});
},
/**
* Change the styling of this view to show that the object is in the process of saving
*/
showSaving: function () {
//Change the style of the save button
this.$("#save-editor")
.html('<i class="icon icon-spinner icon-spin"></i> Submitting ...')
.addClass("btn-disabled");
//Remove all the validation messaging
this.removeValidation();
//Get all the inputs in the Editor
var allInputs = this.$("input, textarea, select, button");
//Mark the disabled inputs so we can re-disable them later
allInputs
.filter(":disabled")
.not(".label-container .label-input-text")
.addClass("disabled-saving");
//Remove the latest success or error alert
this.$el.children(".alert-container").remove();
//Disable all the inputs
allInputs.prop("disabled", true);
},
/**
* Remove the styles set in showSaving()
*/
hideSaving: function () {
this.$("input, textarea, select, button")
.not(".label-container .label-input-text")
.prop("disabled", false);
this.$(".disabled-saving, input.disabled")
.not(".label-container .label-input-text")
.prop("disabled", true)
.removeClass("disabled-saving");
//When the package is saved, revert the Save button back to normal
this.$("#save-editor")
.html(this.submitButtonText)
.removeClass("btn-disabled");
},
/**
* Enable the Save button. Resets any changes made in {@link EditorView#disableControls}
* @since 2.17.1
*/
enableControls: function () {
//When the package is saved, revert the Save button back to normal
this.$("#save-editor")
.html(this.submitButtonText)
.removeClass("btn-disabled")
.parent()
.tooltip("destroy");
},
/**
* Disable the Save button and display a message to explain why
* @param {string} [message] - A short text message to display in the Save button
* @since 2.17.1
*/
disableControls: function (message) {
//When the package is saved, revert the Save button back to normal
this.$("#save-editor")
.html(message || "Waiting for files to finish uploading...")
.addClass("btn-disabled")
.parent() //Add a tooltip to the parent element since tooltips won't work on a disabled button
.tooltip({
placement: "top",
trigger: "hover focus click",
html: false,
title:
"Saving is disabled while files are uploading. Please wait...",
container: "body",
delay: 600,
});
},
/**
* Style the view to show that it is loading
* @param {string|DOMElement} container - The element to put the loading styling in. Either a jQuery selector or the element itself.
* @param {string|DOMElement} message - The message to display next to the loading icon. Either a jQuery selector or the element itself.
*/
showLoading: function (container, message) {
if (typeof container == "undefined" || !container)
var container = this.$el;
$(container).html(MetacatUI.appView.loadingTemplate({ msg: message }));
},
/**
* Remove the styles set in showLoading()
* @param {string|DOMElement} container - The element the loading message is conttained in. Either a jQuery selector or the element itself.
*/
hideLoading: function (container) {
if (typeof container == "undefined" || !container)
var container = this.$el;
$(container).find(".loading").remove();
},
/**
* Called when there is no object found with this ID
*/
showNotFound: function () {
//If we haven't checked the logged-in status of the user yet, wait a bit until we show a 404 msg, in case this content is their private content
if (!MetacatUI.appUserModel.get("checked")) {
this.listenToOnce(
MetacatUI.appUserModel,
"change:checked",
this.showNotFound,
);
return;
}
//If the user is not logged in
else if (!MetacatUI.appUserModel.get("loggedIn")) {
this.showSignIn();
return;
}
if (!this.model.get("notFound")) return;
var msg =
"<h4>Nothing was found for one of the following reasons:</h4>" +
"<ul class='indent'>" +
"<li>The ID <span id='editor-view-not-found-pid'></span> does not exist.</li>" +
'<li>This may be private content. (Are you <a href="<%= MetacatUI.root %>/signin">signed in?</a>)</li>' +
"<li>The content was removed because it was invalid.</li>" +
"</ul>";
//Remove the loading messaging
this.hideLoading();
//Show the not found message
MetacatUI.appView.showAlert(
msg,
"alert-error",
this.$("#editor-body"),
null,
{ remove: true },
);
this.$("#editor-view-not-found-pid").text(this.pid);
},
/**
* Check the validity of this view's model
*/
checkValidity: function () {
if (this.model.isValid()) this.model.trigger("valid");
},
/**
* Show validation errors, if there are any
*/
showValidation: function () {
this.saveError(
"Unable to save. Either required information is missing or isn't filled out correctly.",
);
},
/**
* Removes all the validation error styling and messaging from this view
*/
removeValidation: function () {
this.$(".notification.error").removeClass("error").empty();
this.$(".validation-error-icon").hide();
},
/**
* When the object is saved successfully, tell the user
* @param {object} savedObject - the object that was successfully saved
*/
saveSuccess: function (savedObject) {
var message = this.editorSubmitMessageTemplate({
messageText: "Your changes have been submitted.",
viewURL: MetacatUI.appModel.get("baseUrl"),
buttonText: "Return home",
});
MetacatUI.appView.showAlert(message, "alert-success", this.$el, null, {
remove: true,
});
this.hideSaving();
},
/**
* When the object fails to save, tell the user
* @param {string} errorMsg - The error message resulting from a failed attempt to save
*/
saveError: function (errorMsg) {
var messageContainer = $(document.createElement("div")).append(
document.createElement("p"),
),
messageParagraph = messageContainer.find("p"),
messageClasses = "alert-error";
messageParagraph.append(errorMsg);
//If the model has an error message set on it, show it in a collapseable technical details section
if (this.model.get("errorMessage")) {
var errorId = "error" + Math.round(Math.random() * 100);
messageParagraph.after(
$(document.createElement("p")).append(
$(document.createElement("a"))
.text("See technical details")
.attr("data-toggle", "collapse")
.attr("data-target", "#" + errorId)
.addClass("pointer"),
),
$(document.createElement("div"))
.addClass("collapse")
.attr("id", errorId)
.append(
$(document.createElement("pre")).text(
this.model.get("errorMessage"),
),
),
);
}
MetacatUI.appView.showAlert(
messageContainer,
messageClasses,
this.$el,
null,
{
emailBody: errorMsg,
remove: true,
},
);
this.hideSaving();
},
/**
* Shows the required icons for the sections and fields that must be completed in this editor.
* @param {object} requiredFields - A literal object that specified which fields should be required.
* The keys on the object map to model attributes, and the value is true if required, false if optional.
*/
renderRequiredIcons: function (requiredFields) {
//If no required fields are given, exit now
if (typeof requiredFields == "undefined") {
return;
}
_.each(
Object.keys(requiredFields),
function (field) {
if (requiredFields[field]) {
var reqEl = this.$(
".required-icon[data-category='" + field + "']",
);
//Show the required icon for this category/field
reqEl.show();
//Show the required icon for the section
var sectionName = reqEl
.parents(".section[data-section]")
.attr("data-section");
this.$(
".required-icon[data-section='" + sectionName + "']",
).show();
}
},
this,
);
//When new inputs have been added to this Editor, re-render these required icons.
// This is helpful when new questions are added to the editor after the intial rendering.
this.off("editorInputsAdded");
this.on(
"editorInputsAdded",
function () {
this.renderRequiredIcons(requiredFields);
},
this,
);
},
/**
* Gets a list of required fields for this editor, or an empty object if there are none.
* @returns {object}
* @since 2.19.0
*/
getRequiredFields: function () {
return {};
},
/**
* Checks if there are unsaved changes in this Editor that should prevent closing of this view.
* This function is also executed by the AppView, which controls the top-level navigation.
* @returns {boolean} Returns true if this view should be closed. False if it should remain opened and active.
*/
canClose: function () {
//If the user isn't logged in, we can leave this view without confirmation
if (!MetacatUI.appUserModel.get("loggedIn")) return true;
//If there are no unsaved changes, we can leave this view without confirmation
if (!this.hasUnsavedChanges()) {
return true;
}
return false;
},
/**
* This function is called whenever the user is about to leave this view.
* @returns {string} The message that asks the user if they are sure they want to close this view
*/
getConfirmCloseMessage: function () {
//Return a confirmation message
return "Leave this page? All of your unsaved changes will be lost.";
},
/**
* Returns true if there are unsaved changes in this Editor
* This function should be extended by each subclass of EditorView to check for unsaved changes for that model type
* @returns {boolean}
*/
hasUnsavedChanges: function () {
return true;
},
/**
* Creates an HTML string to display this error message on the page. Errors can be
* strings, arrays of strings, arrays of literal objects with string values, or a literal object with strings as the values.
* @param {string|string[]|object} error A single error message in string format or a collection of error strings as an array or object
* @returns {string} The error message HTML
* @since 2.18.0
*/
getErrorListItem: function (error) {
try {
let errorMessage = "";
//Strings get added to a list item HTML element
if (typeof error == "string" && error.trim().length) {
return `<li>${error}</li>`;
}
//If the error is an array, iterate over each error in the array
else if (Array.isArray(error)) {
_.each(
error,
function (subError) {
errorMessage += this.getErrorListItem(subError);
},
this,
);
return errorMessage;
}
//If the error is a literal object, iterate over each key in the object
else if (typeof error == "object") {
_.each(
Object.keys(error),
function (errorKey) {
errorMessage += this.getErrorListItem(error[errorKey]);
},
this,
);
return errorMessage;
}
//Default to returning an empty string
else {
return "";
}
} catch (e) {
console.error(
"Failed to create the error message to show in the editor: ",
e,
);
return "";
}
},
/**
* Perform clean-up functions when this view is about to be removed from the page or navigated away from.
*/
onClose: function () {
//Remove the listener on the Window
if (this.beforeunloadCallback) {
window.removeEventListener("beforeunload", this.beforeunloadCallback);
delete this.beforeunloadCallback;
}
//Reset the active alternate repository
MetacatUI.appModel.set("activeAlternateRepositoryId", null);
//Remove the class from the body element
$("body").removeClass("Editor rendering");
//Remove listeners
this.stopListening();
this.undelegateEvents();
},
},
);
return EditorView;
});