define([
"underscore",
"jquery",
"backbone",
"sortable",
"models/portals/PortalModel",
"models/portals/PortalSectionModel",
"views/portals/editor/PortEditorSectionView",
"views/portals/editor/PortEditorSettingsView",
"views/portals/editor/PortEditorDataView",
"views/portals/editor/PortEditorMdSectionView",
"text!templates/portals/editor/portEditorSections.html",
"text!templates/portals/editor/portEditorMetrics.html",
"text!templates/portals/editor/portEditorSectionLink.html",
"text!templates/portals/editor/portEditorSectionOptionImgs/metrics.svg",
], function (
_,
$,
Backbone,
Sortable,
Portal,
PortalSection,
PortEditorSectionView,
PortEditorSettingsView,
PortEditorDataView,
PortEditorMdSectionView,
Template,
MetricsSectionTemplate,
SectionLinkTemplate,
MetricsSVG,
) {
/**
* @class PortEditorSectionsView
* @classdesc A view of one or more Portal Editor sections
* @classcategory Views/Portals/Editor
* @extends Backbone.View
* @constructor
*/
var PortEditorSectionsView = Backbone.View.extend(
/** @lends PortEditorSectionsView.prototype */ {
/**
* The type of View this is
* @type {string}
*/
type: "PortEditorSections",
/**
* The HTML tag name for this view's element
* @type {string}
*/
tagName: "div",
/**
* The HTML classes to use for this view's element
* @type {string}
*/
className: "port-editor-sections",
/**
* The PortalModel that is being edited
* @type {Portal}
*/
model: undefined,
/**
* A reference to the currently active editor section. e.g. Data, Metrics, Settings, etc.
* @type {PortEditorSectionView}
*/
activeSection: undefined,
/**
* The name of the active section when the view is first loaded. This is retrieved from the router/URL
* @type {string}
*/
activeSectionLabel: undefined,
/**
* The unique labels for each section in this Portal
* @type {string[]}
*/
sectionLabels: [],
/**
* The subviews contained within this view to be removed with onClose
* @type {Array}
*/
subviews: new Array(),
/**
* A reference to the PortalEditorView
* @type {PortalEditorView}
*/
editorView: undefined,
/**
* References to templates for this view. HTML files are converted to Underscore.js templates
*/
/**
@type {Underscore.Template}
*/
template: _.template(Template),
/**
@type {Underscore.Template}
*/
sectionLinkTemplate: _.template(SectionLinkTemplate),
/**
@type {Underscore.Template}
*/
metricsSectionTemplate: _.template(MetricsSectionTemplate),
/**
* A jQuery selector for the elements that are links to the individual sections
* @type {string}
*/
sectionLinks: ".portal-section-link",
/**
* A jQuery selector for the element that the section links should be inserted into
* @type {string}
*/
sectionLinksContainer: ".section-links-container",
/**
* A jQuery selector for the element that a single section link will be inserted into
* @type {string}
*/
sectionLinkContainer: ".section-link-container",
/**
* A jQuery selector for the element that the editor sections should be inserted into
* @type {string}
*/
sectionsContainer: ".sections-container",
/**
* A jQuery selector for the section elements
* @type {string}
*/
sectionEls: ".port-editor-section",
/**
* A selector for link or tab elements that the user is allowed to re-order,
* starting from the sectionLinksContainer
* @type {string}
*/
sortableLinksSelector: ">li:not(.unsortable)",
/**
* A class name for the handles on tabs that the user can drag to re-order
* @type {string}
*/
handleClass: "handle",
/**
* A label for the section used to add a new page
* @type {string}
*/
addPageLabel: "AddPage",
/**
* Flag to add section name to URL. Enabled by default.
* @type {boolean}
*/
displaySectionInUrl: true,
/**
* @borrows PortalEditorView.newPortalTempName as newPortalTempName
*/
newPortalTempName: "",
/**
* The events this view will listen to and the associated function to call.
* @type {Object}
*/
events: {
"click .rename-section": "renameSection",
"dblclick .portal-section-link": "renameSection",
"click .show-section": "showSection",
"click .portal-section-link": "handleSwitchSection",
"focusout .portal-section-link[contenteditable=true]": "updateName",
"click .cancelled-section-removal": "closePopovers",
"click .confirmed-section-removal": "removeSection",
// both keyup and keydown events are needed for limitLabelLength function
"keyup .portal-section-link[contenteditable=true]": "limitLabelInput",
"keydown .portal-section-link[contenteditable=true]": "limitLabelInput",
"click #link-to-data": "navigateToData",
},
/**
* Creates a new PortEditorSectionsView
* @constructs PortEditorSectionsView
* @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.editorView = null;
// Get all the options and apply them to this view
if (options) {
var optionKeys = Object.keys(options);
_.each(
optionKeys,
function (key, i) {
this[key] = options[key];
},
this,
);
}
},
/**
* Renders the PortEditorSectionsView
*/
render: function () {
//Insert the template into the view
this.$el.html(this.template());
//Render the Data section
this.renderDataSection();
//Render the Metrics section
this.renderMetricsSection();
//Render the Add Section tab
this.renderAddSection();
//Render the Settings
this.renderSettings();
//Render a Section View for each content section in the Portal
this.renderContentSections();
// Disable the delete/hide section option if there is only one section
this.toggleRemoveSectionOption();
var view = this,
linksContainer = view.el.querySelector(view.sectionLinksContainer),
sortableLinksSelector = view.sortableLinksSelector,
sortableLinks = view.el.querySelectorAll(
view.sectionLinksContainer + view.sortableLinksSelector,
),
sortableLinksArray = Array.prototype.slice.call(sortableLinks, 0),
pageOrder = this.model.get("pageOrder");
// Arrange tabs in the order the user has pre-selected
try {
if (pageOrder && pageOrder.length) {
// sort the links according the pageOrder
sortableLinksArray.sort(function (a, b) {
var aName = $(a).data("section-name");
var bName = $(b).data("section-name");
var aIndex = pageOrder.indexOf(aName);
var bIndex = pageOrder.indexOf(bName);
// If the label can't be found in the list of labels, place it at the end
if (bIndex === -1) {
return +1;
}
if (aIndex === -1) {
return -1;
}
// Sort backwards because we use prepend
return bIndex - aIndex;
});
// Rearrange the links in the DOM
for (i = 0; i < sortableLinksArray.length; ++i) {
// Use preprend so that Settings and AddPage tabs remain last in list
linksContainer.prepend(sortableLinksArray[i]);
}
}
} catch (error) {
console.log(
"Error re-arranging tabs according to the pageOrder option. Error message: " +
error,
);
}
// Initialize user-controlled tab re-ordering
var sortable = Sortable.create(linksContainer, {
direction: "horizontal",
easing: "cubic-bezier(1, 0, 0, 1)",
animation: 200,
// Only tabs that have an element with this class will be draggable
handle: "." + view.handleClass,
draggable: sortableLinksSelector,
// When the tab order is changed, update the portal model option with new order
onUpdate: function (evt) {
view.updatePageOrder();
},
});
// Switch to the active section, if there is one
if (this.activeSectionLabel) {
this.activeSection = this.getSectionByLabel(this.activeSectionLabel);
//Switch to the active section
this.switchSection(this.activeSection);
//Reset the active section label, since it is only used during the initial rendering
this.activeSectionLabel = undefined;
} else {
//Switch to the default section
this.switchSection();
}
},
/**
* Render a section for adding a new section
*/
renderAddSection: function () {
//Create a unique label for this section and save it
this.updateSectionLabelsList(this.addPageLabel);
// Add a "Add section" button/tab
var addSectionView = new PortEditorSectionView({
model: this.model,
uniqueSectionLabel: this.addPageLabel,
sectionType: "addpage",
editorView: this.editorView,
});
addSectionView.$el
.addClass("tab-pane")
.addClass("port-editor-add-section-container")
.attr("id", this.addPageLabel);
//Add the section element to this view
this.$(this.sectionsContainer).append(addSectionView.$el);
//Render the section view
addSectionView.render();
// Add the tab to the tab navigation
this.addSectionLink(addSectionView);
// Replace the name "AddSection" with fontawsome "+" icon
this.$el
.find(
this.sectionLinkContainer +
"[data-section-name='" +
this.addPageLabel +
"'] a",
)
.html("<i class='icon icon-plus'></i>")
.attr("title", "Add a new page");
// When a sectionOption is clicked in the addSectionView subview,
// the "addNewSection" event is triggered.
this.listenTo(addSectionView, "addNewSection", this.addSection);
//Add the view to the subviews array
this.subviews.push(addSectionView);
},
/**
* Render all sections in the editor for each content section in the Portal
*/
renderContentSections: function () {
// Get the sections from the Portal
var sections = this.model.get("sections");
// Render each markdown (aka "freeform") section already in the PortalModel
_.each(
sections,
function (section) {
try {
if (section) {
//Render the content section
this.renderContentSection(section);
}
} catch (e) {
console.error(e);
}
},
this,
);
},
/**
* Render a single markdown section in the editor (sectionView + link)
* @param {PortalSectionModel} section - The section to render
* @param {boolean} isNew - If true, this section will be rendered as a section that was just added by the user
*/
renderContentSection: function (section, isNew) {
try {
if (typeof isNew == "undefined" || isNew == null) {
var isNew = false;
}
if (section) {
// Create and render and markdown section view
var sectionView = new PortEditorMdSectionView({
model: section,
});
// Pass the portal editor view onto the section
sectionView.editorView = this.editorView;
//Create a unique label for this section and save it
var uniqueLabel = this.getUniqueSectionLabel(section);
//Set the unique section label for this view
sectionView.uniqueSectionLabel = uniqueLabel;
this.updateSectionLabelsList(uniqueLabel);
//Attach the editor view to this view
sectionView.editorView = this.editorView;
sectionView.$el.attr("id", uniqueLabel);
//Insert the PortEditorMdSectionView element into this view
this.$(this.sectionsContainer).append(sectionView.$el);
//Render the PortEditorMdSectionView
sectionView.render();
// Add the tab to the tab navigation
this.addSectionLink(sectionView, ["Rename", "Delete"], isNew);
// Add the sections to the list of subviews
this.subviews.push(sectionView);
}
} catch (e) {
console.error(e);
}
},
/**
* Renders a Data section in this view
*/
renderDataSection: function () {
try {
this.updateSectionLabelsList("Data");
// Render a PortEditorDataView and corresponding tab
var dataView = new PortEditorDataView({
model: this.model,
uniqueSectionLabel: "Data",
editorView: this.editorView,
});
//Save a reference to this view
this.dataView = dataView;
//Add the view to the page
this.$(this.sectionsContainer).append(dataView.el);
//Render the PortEditorDataView
dataView.render();
//Create the menu options for the Data section link
var menuOptions = [];
if (this.model.get("hideData") === true) {
menuOptions.push("Show");
} else {
menuOptions.push("Hide");
}
// Add the tab to the tab navigation
this.addSectionLink(dataView, menuOptions);
//When the Data section has been hidden or shown, update the section link
this.stopListening(this.model, "change:hideData");
this.listenTo(this.model, "change:hideData", function () {
//Create the menu options for the Data section link
var menuOptions = [];
if (this.model.get("hideData") === true) {
menuOptions.push("Show");
} else {
menuOptions.push("Hide");
}
this.updateSectionLink(dataView, menuOptions);
});
// Add the data section to the list of subviews
this.subviews.push(dataView);
} catch (e) {
console.error(e);
}
},
/**
* Renders the Metrics section of the editor
*/
renderMetricsSection: function () {
// Render a PortEditorSectionView for the Metrics section if metrics is set
// to show, and the view hasn't already been rendered.
if (this.model.get("hideMetrics") !== true && !this.metricsView) {
//Create a unique label for this section and save it
this.updateSectionLabelsList("Metrics");
//Create a section view
this.metricsView = new PortEditorSectionView({
model: this.model,
uniqueSectionLabel: "Metrics",
template: this.metricsSectionTemplate,
sectionType: "metrics",
editorView: this.editorView,
});
this.metricsView.$el.attr("id", "Metrics");
this.$(this.sectionsContainer).append(this.metricsView.el);
//Render the view
this.metricsView.render();
// Insert the metrics illustration
$(this.metricsView.el)
.find(".metrics-figure-container")
.html(MetricsSVG);
// Add the data section to the list of subviews
this.subviews.push(this.metricsView);
}
//If the metrics aren't hidden AND the metrics view was created already, then show it
else if (this.model.get("hideMetrics") !== true && this.metricsView) {
this.$(this.sectionsContainer).append(this.metricsView.$el);
this.metricsView.$el.data({
view: this.metricsView,
model: this.model,
});
}
//When the metrics section has been toggled, remove or add the link
this.toggleMetricsLink();
},
/**
* navigateToData - Navigate to the data tab.
*/
navigateToData: function () {
if (this.dataView) {
this.switchSection(this.dataView);
}
},
/**
* Adds or removes the metrics link depending on the 'hideMetrics' option in
* the model.
*/
toggleMetricsLink: function () {
try {
// Need a metrics view to exist already if metrics is set to show
/* if(!this.metricsView && !this.model.get("hideMetrics") === true){
this.renderMetricsSection();
}*/
//If hideMetrics has been set to true, remove the link
if (this.model.get("hideMetrics") === true) {
this.removeSectionLink(this.metricsView);
// Otherwise add it
} else {
this.addSectionLink(this.metricsView, ["Delete"]);
}
} catch (e) {
console.error(e);
}
},
/**
* Renders the Settings section of the editor
*/
renderSettings: function () {
//Create a unique label for this section and save it
this.updateSectionLabelsList("Settings");
//Create a PortEditorSettingsView
var settingsView = new PortEditorSettingsView({
model: this.model,
uniqueSectionLabel: "Settings",
});
settingsView.editorView = this.editorView;
//Add the Settings view to the page
this.$(this.sectionsContainer).append(settingsView.$el);
//Render the PortEditorSettingsView
settingsView.render();
//Create and add a section link
this.addSectionLink(settingsView);
// Add the data section to the list of subviews
this.subviews.push(settingsView);
},
/**
* Update the window location path with the active section name
* @param {boolean} [showSectionLabel] - If true, the editor section label will be added to the path
*/
updatePath: function (showSectionLabel) {
//Get the current portal label
var label = this.model.get("label") || "",
//Get the last-saved portal label
originalLabel = this.model.get("originalLabel") || "",
//Get the current path from the window location
pathName = decodeURIComponent(window.location.pathname)
.substring(MetacatUI.root.length)
// remove trailing forward slash if one exists in path
.replace(/\/$/, "");
// Add or replace the label part of the path with the new label.
// pathRE matches "/label/section", where the "/section" part is optional
var pathRE = new RegExp(
"\\/(" + label + "|" + originalLabel + ")(\\/[^\\/]*)?$",
"i",
);
newPathName = pathName.replace(pathRE, "");
//If there is a label, add it to the new path.
// (there will always be a label unless this is a new portal)
if (label) {
newPathName += "/" + label;
}
//If there is an active section, and the showSectionLabel parameter is true, add the section label to the path.
if (showSectionLabel && this.activeSection) {
newPathName += "/" + this.activeSection.uniqueSectionLabel;
}
//If the path has changed, navigate to the new path, which creates a record in the browser history
if (pathName != newPathName) {
// Update the window location
MetacatUI.uiRouter.navigate(newPathName, {
trigger: false,
});
}
},
/**
* Returns the section view that has a label matching the one given.
* @param {string} label - The label for the section
* @return {PortEditorSectionView|false} - Returns false if a matching section view isn't found
*/
getSectionByLabel: function (label) {
//If no label is given, exit
if (!label) {
return;
}
//Find the section view whose unique label matches the given label. Case-insensitive matching.
return _.find(this.subviews, function (view) {
if (typeof view.uniqueSectionLabel == "string") {
return view.uniqueSectionLabel.toLowerCase() == label.toLowerCase();
} else {
return false;
}
});
},
/**
* Returns the section view that has a label matching the one given.
* @param {PortalSectionModel} section - The section model
* @return {PortEditorSectionView|false} - Returns false if a matching section view isn't found
*/
getSectionByModel: function (section) {
//If no section is given, exit
if (!section) {
return;
}
//Find the section view whose unique label matches the given label. Case-insensitive matching.
return _.findWhere(this.subviews, { model: section });
},
/**
* Creates and returns a unique label for the given section. This label is just used in the view,
* because portal sections can have duplicate labels. But unique labels need to be used for navigation in the view.
* @param {PortEditorSection} sectionModel - The section for which to create a unique label
* @return {string} The unique label string
*/
getUniqueSectionLabel: function (sectionModel) {
//Get the label for this section
var sectionLabel = sectionModel
.get("label")
.replace(/[^a-zA-Z0-9 ]/g, "")
.replace(/ /g, "-"),
unalteredLabel = sectionLabel,
sectionLabels = this.sectionLabels || [],
i = 2;
//Concatenate a number to the label if this one already exists
while (sectionLabels.includes(sectionLabel)) {
sectionLabel = unalteredLabel + i;
i++;
}
return sectionLabel;
},
/**
* Manually switch to a section subview by making the tab and tab panel active.
* Navigation between sections is usually handled automatically by the Bootstrap
* library, but a manual switch may be necessary sometimes
* @param {PortEditorSectionView} [sectionView] - The section view to switch to. If not given, defaults to the activeSection set on the view.
*/
switchSection: function (sectionView) {
//Create a flag for whether the section label should be shown in the URL
var showSectionLabelInURL = true;
// If no section view is given, use the active section in the view.
if (!sectionView) {
//Use the sectionView set already
if (this.activeSection) {
var sectionView = this.activeSection;
}
//Or find the section view by name, which may have been passed through the URL
else if (this.activeSectionLabel) {
var sectionView = this.getSectionByLabel(this.activeSectionLabel);
}
}
//If no section view was indicated, just default to the first visible one
if (!sectionView) {
var sectionView = this.$(
this.sectionLinkContainer + ":not(.removing)",
)
.first()
.data("view");
//If we are defaulting to the first section, don't show the section label in the URL
showSectionLabelInURL = false;
//If there are no section views on the page at all, exit now
if (!sectionView) {
return;
}
}
// Update the activeSection set on the view
this.activeSection = sectionView;
// Activate the section content
this.$(this.sectionEls).each(function (i, contentEl) {
if ($(contentEl).data("view") == sectionView) {
$(contentEl).addClass("active");
sectionView.trigger("active");
} else {
// make sure no other sections are active
$(contentEl).removeClass("active");
}
});
// Activate the link to the content
this.$(this.sectionLinkContainer).each(function (i, linkEl) {
if ($(linkEl).data("view") == sectionView) {
$(linkEl).addClass("active");
} else {
// make sure no other sections are active
$(linkEl).removeClass("active");
}
});
//Never show the section label in the URL, since it messes with the back/forward browser navigation. See #1364
showSectionLabelInURL = false;
//Update the location path
this.updatePath(showSectionLabelInURL);
},
/**
* When a section link has been clicked, switch to that section
* @param {Event} e - The click event on the section link
*/
handleSwitchSection: function (e) {
e.preventDefault();
// Make sure any markdown editor toolbar modals are closed
// (otherwise they persist in new tab)
$("body").find(".wk-prompt").remove();
// Make sure any markdown editor toolbar modals are closed
// (otherwise they persist in new tab)
$("body").find(".wk-prompt").remove();
var sectionView = $(e.target)
.parents(this.sectionLinkContainer)
.first()
.data("view");
if (sectionView) {
this.switchSection(sectionView);
// If the user clicks a link and is not near the top of the page
// (i.e. on mobile), scroll to the top of the section content.
// Otherwise it might look like the page hasn't changed
if (window.pageYOffset > $("#editor-body").offset().top) {
MetacatUI.appView.scrollTo($("#editor-body"));
}
}
},
/**
* Add a link to the given editor section
* @param {PortEditorSectionView} sectionView - The view to add a link to
* @param {string[]} menuOptions - An array of menu options for this section. e.g. Rename, Delete
* @param {boolean} isFocused - A boolean flag to enable focus on new section link
*/
addSectionLink: function (sectionView, menuOptions, isFocused) {
try {
if (typeof isFocused != "boolean") {
var isFocused = false;
}
var view = this;
var newLink = this.createSectionLink(sectionView, menuOptions);
var isMarkdownSection =
$(newLink).data("view").type == "PortEditorMdSection";
// Make the tab hidden to start
$(newLink)
.find(this.sectionLinks)
.css("max-width", "0px")
.css("opacity", "0.2")
.css("white-space", "nowrap");
$(newLink)
.find(".section-menu-link")
.css("opacity", "0.5")
.css("transition", "opacity 0.1s");
// Find the "+" link to help determine the order in which we should add links
var addSectionEl = this.$(this.sectionLinksContainer).find(
this.sectionLinkContainer +
"[data-section-name='" +
this.addPageLabel +
"']",
)[0];
// If the new link is for a markdown section and there's no user-defined page
// order, then insert the markdown sections before the data and metrics section
// (this is the default order when there is no page ordering).
if (
isMarkdownSection &&
(!view.model.get("pageOrder") ||
!view.model.get("pageOrder").length)
) {
// Find the last markdown section in the list of links
var currentLinks = this.$(this.sectionLinksContainer).find(
this.sectionLinkContainer,
);
var i = _.map(currentLinks, function (li) {
return $(li).data("view") ? $(li).data("view").type : "";
}).lastIndexOf("PortEditorMdSection");
var lastMdSection = currentLinks[i];
// Append the new link after the last markdown section, or add it first.
if (lastMdSection) {
$(lastMdSection).after(newLink);
} else {
this.$(this.sectionLinksContainer).prepend(newLink);
}
// If there is already some user-defined page ordering, or if not a markdown
// section and not the Settings section, and if there is already a "+" link, add
// new link before the "+" link
} else if (
addSectionEl &&
sectionView.uniqueSectionLabel != "Settings"
) {
$(addSectionEl).before(newLink);
// If the new link is "Settings", or there's no "+" link yet, insert new link last.
} else {
this.$(this.sectionLinksContainer).append(newLink);
}
// If this is a newly added markdown section, highlight the section name and make
// it content editable. Currently only markdown sections labels are editable.
if (isFocused && isMarkdownSection) {
var newSectionLink = $(newLink).children(".portal-section-link");
newSectionLink.attr("contenteditable", true);
newSectionLink.focus();
//Select the text of the link
if (window.getSelection && window.document.createRange) {
var selection = window.getSelection();
var range = window.document.createRange();
range.selectNodeContents(newSectionLink[0]);
selection.removeAllRanges();
selection.addRange(range);
} else if (window.document.body.createTextRange) {
range = window.document.body.createTextRange();
range.moveToElementText(newSectionLink[0]);
range.select();
}
}
// Animate the link to full width / opacity
$(newLink)
.find(this.sectionLinks)
.animate(
{
"max-width": "500px",
overflow: "hidden",
opacity: 1,
},
{
duration: 300,
complete: function () {
$(newLink).find(".section-menu-link").css("opacity", "1");
},
},
);
} catch (e) {
console.error(
"Could not add a new section link. Error message: " + e,
);
}
},
/**
* toggleRemoveSectionOption - Disables the hide and remove option from
* section link if it's the only section left. Re-enables the remove/hide
* link when a new section is added. Called on initial render and each time
* a section is added, removed, shown, or hidden.
*/
toggleRemoveSectionOption: function () {
try {
// Determine the number of pages (sections + metrics + data)
var totalPages =
this.model.get("sections").length +
!this.model.get("hideMetrics") +
!this.model.get("hideData"),
removeSectionLinks = this.$(this.sectionLinkContainer).find(
".remove-section",
);
// If there's just one section, hide the delete and hide option on last
// remaining section link
if (totalPages == 1) {
removeSectionLinks.addClass("disabled");
if (!removeSectionLinks.closest("li").find(".tooltip").length) {
removeSectionLinks.closest("li").tooltip({
placement: "bottom",
trigger: "hover",
title:
"At least one displayed page is required. To remove this page, first add or show another page.",
});
}
// If there are 2 sections, re-show the delete or hide options.
} else if (totalPages == 2) {
removeSectionLinks.removeClass("disabled");
removeSectionLinks.closest("li").tooltip("destroy");
// If there are three or more pages, nothing needs to be changed.
} else {
return;
}
} catch (e) {
console.log(
"Failure to show/hide the remove section option. Error message: " +
e,
);
}
},
/**
* Add a link to the given editor section
* @param {PortEditorSectionView} sectionView - The view to add a link to
* @param {string[]} menuOptions - An array of menu options for this section. e.g. Rename, Delete
* @return {Element}
*/
createSectionLink: function (sectionView, menuOptions) {
var classes = "";
if (Array.isArray(menuOptions) && menuOptions.includes("Show")) {
classes = "hidden-section";
}
// Do not allow dragging/sorting of the Settings or Add Page sections
var sortable = true;
if (["addpage", "settings"].includes(sectionView.sectionType)) {
sortable = false;
}
//Create a section link
var sectionLink = $(
this.sectionLinkTemplate({
menuOptions: menuOptions || [],
uniqueLabel: sectionView.uniqueSectionLabel,
sectionLabel: PortalSection.prototype.isPrototypeOf(
sectionView.model,
)
? sectionView.model.get("label")
: sectionView.uniqueSectionLabel,
sectionURL:
this.model.get("label") + "/" + sectionView.uniqueSectionLabel,
sectionType: sectionView.sectionType,
classes: classes,
handleClass: this.handleClass,
sortable: sortable,
}),
);
//Attach the section model to the link
sectionLink.data({
model: sectionView.model,
view: sectionView,
});
if (
sectionView.sectionType == "freeform" &&
menuOptions.includes("Delete")
) {
var content = $(document.createElement("div")).append(
$(document.createElement("div")).append(
$(document.createElement("p")).text(
"Deleting this page will premanently remove it from this " +
MetacatUI.appModel.get("portalTermSingular") +
".",
),
),
$(document.createElement("div"))
.addClass("inline-buttons")
.append(
$(document.createElement("button"))
.addClass("btn cancelled-section-removal")
.text("No, keep page"),
$(document.createElement("button"))
.addClass("btn btn-danger confirmed-section-removal")
.text("Yes, delete page"),
),
);
// Create a popover with the confirmation buttons
sectionLink.find(".remove-section").addClass("popover-this").popover({
html: true,
placement: "right",
title: "Delete this page?",
content: content,
container: sectionLink,
trigger: "click",
});
}
return sectionLink[0];
},
/**
* Add a link to the given editor section
* @param {PortEditorSectionView} sectionView - The view to add a link to
* @param {string[]} menuOptions - An array of menu options for this section. e.g. Rename, Delete
*/
updateSectionLink: function (sectionView, menuOptions) {
//Create a new link to the section
var sectionLink = this.createSectionLink(sectionView, menuOptions);
//Replace the existing link
this.$(this.sectionLinksContainer)
.children()
.each(function (i, link) {
if ($(link).data("view") == sectionView) {
$(link).replaceWith(sectionLink);
}
});
},
/**
* Remove the link to the given section view
* @param {View} sectionView - The view to remove the link to
*/
removeSectionLink: function (sectionView) {
// Switch to the default section the user is deleting the active section
if (sectionView == this.activeSection) {
this.switchSection();
}
try {
var view = this;
//Find the section link associated with this section view
this.$(this.sectionLinksContainer)
.children()
.each(function (i, link) {
if ($(link).data("view") == sectionView) {
//Remove the menu link
$(link)
.addClass("removing")
.find(".section-menu-link")
.remove();
//Destroy any popovers
$(link).popover("destroy");
$(link).find(".popover-this").popover("destroy");
//Hide the section name link with an animation
$(link).animate(
{ width: "0px", overflow: "hidden" },
{
duration: 300,
complete: function () {
this.remove();
// If there's a page order option set on the model, update it
var pageOrder = view.model.get("pageOrder");
if (pageOrder && pageOrder.length) {
view.updatePageOrder();
}
},
},
);
}
});
} catch (e) {
console.error(e);
}
},
/**
* Adds a section and tab to this view and the PortalModel
* @param {string} sectionType - The type of section to add
*/
addSection: function (sectionType) {
try {
//Create a new section to the Portal model
this.model.addSection(sectionType);
if (typeof sectionType == "string") {
switch (sectionType.toLowerCase()) {
case "data":
this.switchSection(this.dataView);
break;
case "metrics":
this.renderMetricsSection();
this.switchSection(this.metricsView);
break;
case "freeform":
// Set up page ordering if it isn't already. This allows us to add a new
// freeform page at the end of the list of tabs, instead of before Data and
// Metrics (the default before page ordering was enabled).
var pageOrder = this.model.get("pageOrder");
if (!pageOrder || !pageOrder.length) {
this.updatePageOrder();
}
// Get the section model that was just added
var newestSection =
this.model.get("sections")[
this.model.get("sections").length - 1
];
//Render the content section view for it
this.renderContentSection(newestSection, true);
//Switch to that new view
this.switchSection(this.getSectionByModel(newestSection));
break;
case "members":
// TODO
// this.switchSection(this.getSectionByLabel("Members"));
break;
}
// If the section we just added is now one of two sections, re-enable
// the hide/delete button on the other section link.
this.toggleRemoveSectionOption();
this.editorView.showControls();
// Add the item to the the pageOrder option on the model, if it exists
var pageOrder = this.model.get("pageOrder");
if (pageOrder && pageOrder.length) {
this.updatePageOrder();
}
} else {
return;
}
} catch (e) {
console.error(e);
}
},
/**
* Removes a section and its tab from this view and the PortalModel.
* At least one of the parameters is required, but not both
* @param {Event} [e] - (optional) The click event on the Remove button
* @param {Element|jQuery} [sectionLink] - The link element of the section to be removed.
*/
removeSection: function (e, sectionLink) {
try {
if (!sectionLink || !sectionLink.length) {
var clickedEl = $(e.target);
//Get the PortalSection model for this remove button
var sectionLink = clickedEl
.parents(this.sectionLinkContainer)
.first();
//Exit if no section link was found
if (!sectionLink || !sectionLink.length) {
return;
}
}
//Get the section model and view
var sectionModel = sectionLink.data("model"),
sectionView = sectionLink.data("view"),
sectionType = sectionLink.data("section-type"),
uniqueSectionLabel = sectionView.uniqueSectionLabel,
sectionLabelIndex = this.sectionLabels.indexOf(uniqueSectionLabel);
if (PortalSection.prototype.isPrototypeOf(sectionModel)) {
// Remove this section from the Portal
this.model.removeSection(sectionModel);
// Remove the section label from the list of unique section labels
if (sectionLabelIndex > -1) {
this.sectionLabels.splice(sectionLabelIndex, 1);
}
} else {
//Remove this section type from the model
this.model.removeSection(sectionType);
}
try {
//If no section view was found, exit now
if (!sectionView) {
return;
}
//If this is not the Data section, remove the view, since Data sections can only be hidden
if (sectionType.toLowerCase() != "data") {
// remove the sectionView
this.removeSectionLink(sectionView);
// remove the section view from the subviews array
this.subviews.splice($.inArray(sectionView, this.subviews), 1);
//Remove the view from the page
sectionView.$el.remove();
//Reset the active section, if the one that was removed is currently active
if (this.activeSection == sectionView) {
this.activeSection = undefined;
//Switch to the default section
this.switchSection();
}
}
} catch (error) {
console.error(error);
}
// If the section just removed was the second-to-last section, disable
// the hide/delete button on the last section link.
this.toggleRemoveSectionOption();
this.editorView.showControls();
} catch (e) {
console.error(e);
MetacatUI.appView.showAlert(
"The section could not be deleted. (" + e.message + ")",
"alert-error",
);
}
},
/**
* Shows a previously-hidden section
* @param {Event} [e] - (optional) The click event on the Show button
*/
showSection: function (e) {
try {
//Get the PortalSection model for this show button
var sectionLink = $(e.target).parents(this.sectionLinkContainer),
section = sectionLink.data("model");
//If this section is not a PortalSection model, get the section type
if (!PortalSection.prototype.isPrototypeOf(section)) {
section = sectionLink.data("section-type");
}
//If no section was found, exit now
if (!section) {
return;
}
//Mark this section as shown
this.model.addSection(section);
// If the section we're now showing is now one of two sections, re-enable
// the hide/delete button on the other section link.
this.toggleRemoveSectionOption();
} catch (e) {
console.error(e);
MetacatUI.appView.showAlert(
"The section could not be shown. (" + e.message + ")",
"alert-error",
);
}
},
/**
* Renames a section in the tab in this view and in the PortalSectionModel
* @param {Event} [e] - (optional) The click event on the Rename button
*/
renameSection: function (e) {
try {
//Get the PortalSection model for this rename button
var sectionLink = $(e.target).parents(this.sectionLinkContainer),
targetLink = sectionLink.children(this.sectionLinks),
section = sectionLink.data("model");
// double-click events
if (e.type === "dblclick") {
// Continue editing tab-name on double click only for markdown sections
if ($(sectionLink).data("view").type != "PortEditorMdSection") {
return;
}
}
// make the text editable
targetLink.attr("contenteditable", true);
// add focus to the text
targetLink.focus();
//Select the text of the link
if (window.getSelection && window.document.createRange) {
var selection = window.getSelection();
var range = window.document.createRange();
range.selectNodeContents(targetLink[0]);
selection.removeAllRanges();
selection.addRange(range);
} else if (window.document.body.createTextRange) {
range = window.document.body.createTextRange();
range.moveToElementText(targetLink[0]);
range.select();
}
} catch (error) {
console.error(error);
}
},
/**
* Stops user from entering more than 50 characters, and shows a message
* if user tries to exceed the limit. Also stops a user from entering
* RETURN or TAB characters, and instead re-directs to updateName().
* In the case of the TAB key, the focus moves to the title field.
* @param {Event} e - The keyup or keydown event when the user types in the portal-section-link field
*/
limitLabelInput: function (e) {
try {
// Character limit for the labels
var limit = 50;
var currentLabel = $(e.target).text();
// If the RETURN key is pressed
if (e.which == 13) {
// Don't allow character to be entered
e.preventDefault();
e.stopPropagation();
// Update name and exit function
this.updateName(e);
return;
}
// If the TAB key is pressed
if (e.which == 9) {
// Don't allow character to be entered
e.preventDefault();
e.stopPropagation();
// Update name, change focus to title, and exit function
this.updateName(e);
$("textarea.title").focus();
return;
}
// Keys that a user can use as normal, even if character limit is met
var allowedKeys = [
8, // DELETE
35, // END
36, // HOME
37, // LEFT
38, // UP
39, // RIGHT
40, // DOWN
46, // DEL
17, // CTRL
];
// Stop addition of more characters and show message
if (
// If at or greater than limit and
currentLabel.length >= limit &&
// key isn't a special key and
!allowedKeys.includes(e.which) &&
// cmd key isn't held down and
!e.metaKey &&
// user doesn't have some of the text selected
!window.getSelection().toString().length
) {
// Don't allow character to be entered
e.preventDefault();
e.stopPropagation();
// Add a tooltip if one doesn't exist yet
if (!$(e.delegateTarget).find(".tooltip").length) {
$(e.target).tooltip({
placement: "top",
trigger: "manual",
title: "Limit of " + limit + " characters or fewer",
});
}
// Show the tooltip
$(e.target).tooltip("show");
// If under the character limit, proceed as normal.
} else {
// Make sure there's no tooltip showing.
$(e.delegateTarget).find(".tooltip").remove();
}
} catch (error) {
"Error limiting user input in label field, error message: " + error;
}
},
/**
* Update the section label
*
* @param e The event triggering this method
*/
updateName: function (e) {
// Remove tooltip incase one was set by limitLabelInput function
$(e.delegateTarget).find(".tooltip").remove();
try {
//Get the PortalSection model for this rename button
var sectionLink = $(e.target).parents(this.sectionLinkContainer),
targetLink = sectionLink.find(this.sectionLinks),
sectionModel = sectionLink.data("model"),
sectionView = sectionLink.data("view"),
// Clean up the typed in name so it's valid for XML
oldLabel = sectionModel.get("label"),
newLabel = this.model.cleanXMLText(targetLink.text().trim()),
pageOrder = this.model.get("pageOrder");
// Remove the content editable attribute
targetLink.attr("contenteditable", false);
// If this section is an object of PortalSection model, update the label.
if (
sectionModel &&
PortalSection.prototype.isPrototypeOf(sectionModel)
) {
// update the label on the model
sectionModel.set("label", newLabel);
} else {
// TODO: handle the case for non-markdown sections
}
// Update the name set on the link element, since it's used for setting the pageOrder option
sectionLink.data("section-name", newLabel);
// Update the name in the pageOrder option, if it exists
if (pageOrder && pageOrder.length) {
this.updatePageOrder();
}
// Update the array of unique section labels
// Get the position of the unique label in the list
var indexIDs = this.sectionLabels.indexOf(
sectionView.uniqueSectionLabel,
),
newUniqueLabel = "";
// Remove the old unique label so when we create a new unique label,
// we don't consider the label that we're replacing in determining uniqueness
if (indexIDs > -1) {
this.sectionLabels.splice(indexIDs, 1);
newUniqueLabel = this.getUniqueSectionLabel(sectionModel);
this.sectionLabels.splice(indexIDs, 0, newUniqueLabel);
} else {
newUniqueLabel = this.getUniqueSectionLabel(sectionModel);
this.sectionLabels.push(newUniqueLabel);
}
// Update the label set on the view
sectionView.uniqueSectionLabel = newUniqueLabel;
} catch (error) {
console.error(error);
}
},
/**
* Using the "section-name" data attribute set on each section link,
* and the order that the links are displayed in the DOM, update the
* pageOrder option in the portal model.
*/
updatePageOrder: function () {
try {
var view = this;
// Get the links as they are ordered in the UI
var links = view.el.querySelectorAll(
view.sectionLinksContainer + view.sortableLinksSelector,
),
pageOrder = [];
_.each(links, function (link) {
// Use the value set on section-name to re-order pages
var label = $(link).data("section-name");
if (label) {
pageOrder.push(label);
}
});
view.model.set("pageOrder", pageOrder);
view.editorView.showControls();
} catch (error) {
console.log(
"Error updating the portal page order, message: " + error,
);
}
},
/**
* Add a new unique label to the list of unique section labels
* (used the ensure that tab links and anchors are unique,
* otherwise, tab switching does not work)
*/
updateSectionLabelsList: function (newLabel) {
try {
if (!this.sectionLabels) {
this.sectionLabels = [];
}
this.sectionLabels.push(newLabel);
} catch (error) {
console.log(
"Error updating the list of unique section labels. Error message: " +
error,
);
}
},
/**
* 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
*/
showValidation: function (elements) {
try {
//Get the parent elements that have ids set
var sectionEls = elements.parents(this.sectionEls);
//See if there is a matching section link
for (var i = 0; i < sectionEls.length; i++) {
//Get the section view attached to the section element
var sectionView = sectionEls.data("view");
//If a section view was found,
if (sectionView) {
//Find the section link that links to this section view
var matchingLink = _.find(
$(this.sectionLinkContainer),
function (link) {
return $(link).data("view") == sectionView;
},
);
//Add the error class and display the error icon
if (matchingLink) {
$(matchingLink).addClass("error").find(".icon.error").show();
//Exit the loop
i = sectionEls.length + 1;
}
}
}
} catch (e) {
console.error("Error showing validation message: ", e);
}
},
/**
* Closes all the popovers in this view
*/
closePopovers: function () {
this.$(".popover-this").popover("hide");
},
/**
* This function is called when the app navigates away from this view.
* Any clean-up or housekeeping happens at this time.
*/
onClose: function () {
//Remove each subview from the DOM and remove listeners
_.invoke(this.subviews, "remove");
this.subviews = new Array();
//Remove the reference to the EditorView
this.editorView = null;
},
},
);
return PortEditorSectionsView;
});