define([
"jquery",
"underscore",
"backbone",
"collections/Filters",
"models/filters/Filter",
"collections/SolrResults",
"views/PagerView",
"text!templates/portals/portalList.html",
], ($, _, Backbone, Filters, Filter, SearchResults, PagerView, Template) => {
const CLASS_NAMES = {
CREATE: ["btn", "btn-primary"],
ICON: ["icon", "icon-plus", "icon-on-left"],
LOADING: "loading",
STATUS: "portal-list-status",
};
const NEW_PORTAL_LABEL = "New ";
/**
* @class PortalListView
* @classdesc A view that shows a list of Portals
* @classcategory Views/Portals
* @augments Backbone.View
* @screenshot views/portals/PortalListView.png
* @class
*/
const PortalListView = Backbone.View.extend(
/** @lends PortalListView.prototype */ {
/**
* An array of Filter models or Filter model JSON to use in the query.
* If not provided, a default query will be used.
* @type {Filter[]}
*/
filters: null,
/**
* The general search term applied to the portal's indexed metadata.
* @type {string}
*/
searchTerm: "",
/**
* A SolrResults collection that contains the results of the search for the portals
* @type {SolrResults}
*/
searchResults: new SearchResults(),
/**
* A comma-separated list of Solr index fields to retrieve when searching for portals
* @type {string}
* @default "id,seriesId,title,formatId,label,logo"
*/
searchFields:
"id,seriesId,title,formatId,label,logo,datasource,writePermission,changePermission,rightsHolder,abstract",
/**
* The number of portals to dispaly per page
* @default 10
* @type {number}
*/
numPortalsPerPage: 10,
/**
* The number of portals to retrieve and render in this view
* @default 100
* @type {number}
*/
numPortals: 100,
/**
* An array of additional SolrResult models for portals that will be displayed
* in this view in addition to the SolrResults found as a result of the search.
* These could be portals that wouldn't otherwise be found by a search but should be displayed anyway.
* @type {SolrResult[]}
*/
additionalPortalsToDisplay: [],
/**
* The message to display when there are no portals in this list
* @type {string}
*/
noResultsMessage: `You haven't created or have access to any ${MetacatUI.appModel.get(
"portalTermPlural",
)} yet.`,
/**
* A jQuery selector for the element that the list should be inserted into
* @type {string}
*/
listContainer: ".portal-list-container",
/**
* A jQuery selector for the element that the Create Portal should be inserted into
* @type {string}
*/
createBtnContainer: ".create-btn-container",
/**
* References to templates for this view. HTML files are converted to Underscore.js templates
*/
template: _.template(Template),
/**
* Creates the result collection and tracks its active request.
* @returns {void}
*/
initialize() {
this.searchResults = new SearchResults();
this.listenTo(this.searchResults, "request", (_collection, request) => {
this.searchRequest = request;
});
},
/**
* Renders the list of portals
*/
render() {
try {
// If the "my portals" feature is disabled, exit now
if (MetacatUI.appModel.get("showMyPortals") === false) {
return;
}
// Insert the template
this.$el.html(this.template());
this.createBtnContainerEl = this.el.querySelector(
this.createBtnContainer,
);
this.listContainerEl = this.el.querySelector(this.listContainer);
this.$listContainerEl = $(this.listContainerEl);
// If there are no given filters, create default ones
if (!this.filters) {
// Create search filters for finding the portals
const filters = new Filters();
// Filter datasets that the user has ownership of
filters.addWritePermissionFilter();
this.filters = filters;
}
// If the filters set on this view is an array of JSON, add it to a Filters collection
else if (
this.filters.length &&
!Object.prototype.isPrototypeOf.call(
Filters.prototype,
this.filters,
)
) {
// Create search filters for finding the portals
const filters = new Filters();
filters.add(this.filters);
this.filters = filters;
}
// If there is an empty array, create a new Filters collection
else if (!this.filters.length) {
this.filters = new Filters();
}
// Get the search results and render them
this.getSearchResults();
// Display any additional portals in the list that have been passed to
// the view directly.
_.each(
this.additionalPortalsToDisplay,
function renderAdditionalPortal(searchResult) {
// Remove any 'loading' elements before adding items to the list
this.hideLoading();
// Create a list item element and add the search result element
// to the list container
this.$listContainerEl.append(this.createListItem(searchResult));
},
this,
);
if (this.additionalPortalsToDisplay.length) {
// While the search is being sent for the other portals in this list,
// show a loading sign underneath the additional portals we just displayed.
const loadingListItem = this.createListItem();
loadingListItem.html(
`<td class='loading subtle' colspan='4'>Loading more ${MetacatUI.appModel.get(
"portalTermPlural",
)}...</td>`,
);
this.$listContainerEl.append(loadingListItem);
}
} catch (e) {
// eslint-disable-next-line no-console
console.error(e);
}
},
/** Remove loading indicators from the portal list */
hideLoading() {
if (this.listContainerEl) {
const loadingElements = this.listContainerEl.querySelectorAll(
`.${CLASS_NAMES.LOADING}`,
);
loadingElements.forEach((el) => el.remove());
}
},
/**
* Queries for the portal objects using the SearchResults collection
*/
getSearchResults() {
try {
this.stopListening(this.searchResults, "reset", this.renderList);
this.stopListening(this.searchResults, "error", this.showError);
this.searchRequest?.abort();
this.pagerView?.remove();
this.$(`.${CLASS_NAMES.STATUS}`).empty();
this.$listContainerEl.html(
$(this.template()).filter(this.listContainer).html(),
);
// Stable IDs keep these required filters from accumulating on resubmission.
this.filters.add({
id: "portal-format",
fields: ["formatId"],
values: ["dataone.org/portals"],
matchSubstring: true,
exclude: false,
});
this.filters.add({
id: "latest-portals",
fields: ["obsoletedBy"],
values: ["*"],
matchSubstring: false,
exclude: true,
});
// Get 100 rows
this.searchResults.rows = this.numPortals;
// The fields to return
this.searchResults.fields = this.searchFields;
// Set the query on the SearchResults
let query = this.filters.getQuery();
if (this.searchTerm) {
const searchFilter = new Filter({
fields: [
"title",
"abstract",
"label",
"investigator",
"funderName",
"funderIdentifier",
"awardNumber",
"collectionQuery",
],
fieldsOperator: "OR",
values: [this.searchTerm],
});
query += `%20AND%20${searchFilter.getQuery()}`;
}
this.searchResults.setQuery(query);
// Listen to the search results collection and render the results when the search is complete
this.listenToOnce(this.searchResults, "reset", this.renderList);
// Listen to the search results collection for errors
this.listenToOnce(this.searchResults, "error", this.showError);
// Get the first page of results
this.searchResults.toPage(0);
} catch (e) {
this.showError();
// eslint-disable-next-line no-console
console.error(
"Failed to fetch the SearchResults for the PortalsList: ",
e,
);
}
},
/**
* Renders each search result from the SolrResults collection
*/
renderList() {
try {
// Remove any 'loading' elements before adding items to the list
this.hideLoading();
if (this.createBtnContainerEl) {
// Update the creation link when permission changes.
const event = [
MetacatUI.appUserModel,
"change:isAuthorizedCreatePortal",
this.renderCreateButton,
];
this.stopListening(...event);
this.listenTo(...event);
MetacatUI.appUserModel.isAuthorizedCreatePortal();
this.renderCreateButton();
}
// If no search results were found, display a message
if (
(!this.searchResults || !this.searchResults.length) &&
!this.additionalPortalsToDisplay.length
) {
const row = this.createListItem();
const message = this.searchTerm
? `No matching ${MetacatUI.appModel.get("portalTermPlural")}.`
: this.noResultsMessage;
row.html(`<div class='no-results'>${message}</div>`);
this.$(`.${CLASS_NAMES.STATUS}`).html(row);
return;
}
// Iterate over each search result and render it
this.searchResults.each(function renderPortal(searchResult) {
// Create a list item element and add the search result element
// to the list container
this.$listContainerEl.append(this.createListItem(searchResult));
}, this);
// Create a pager for this list if there are many portals
if (this.$(".portals-list-entry").length > this.numPortalsPerPage) {
this.pagerView = new PagerView({
pages: this.$(".portals-list-entry"),
itemsPerPage: this.numPortalsPerPage,
});
this.$el.append(this.pagerView.render().el);
}
} catch (e) {
// eslint-disable-next-line no-console
console.error(e);
this.showError();
}
},
/**
* Creates a list entry for the given portal SolrResult model.
* @param {SolrResult} [searchResult] The portal to display.
* @returns {jQuery|string} The list entry, or an empty string on error.
*/
createListItem(searchResult) {
try {
const listItem = $(document.createElement("div")).addClass(
"portals-list-entry",
);
if (searchResult && typeof searchResult.get === "function") {
// Don't render a list item for a portal that is already there
if (
this.$(`tr[data-seriesId='${searchResult.get("seriesId")}']`)
.length
) {
return listItem;
}
// Add an id to the list element
listItem.attr("data-seriesId", searchResult.get("seriesId"));
// Create a logo image
let logoDiv = "";
// Add link to the portal to the list item
const link = $(document.createElement("a")).attr(
"href",
`${MetacatUI.root}/${MetacatUI.appModel.get(
"portalTermPlural",
)}/${encodeURIComponent(
searchResult.get("label") ||
searchResult.get("seriesId") ||
searchResult.get("id"),
)}`,
);
if (searchResult.get("logo")) {
if (!searchResult.get("logo").startsWith("http")) {
let urlBase = "";
// If there are alt repos configured, use the datasource obbject service URL
if (
MetacatUI.appModel.get("alternateRepositories").length &&
searchResult.get("datasource")
) {
const sourceMN = _.findWhere(
MetacatUI.appModel.get("alternateRepositories"),
{ identifier: searchResult.get("datasource") },
);
if (sourceMN) {
urlBase = sourceMN.objectServiceUrl;
}
}
if (!urlBase) {
// use the resolve service if there is no object service url
// (e.g. in DataONE theme)
urlBase =
MetacatUI.appModel.get("objectServiceUrl") ||
MetacatUI.appModel.get("resolveServiceUrl");
}
searchResult.set("logo", urlBase + searchResult.get("logo"));
}
const logoImg = $(document.createElement("img"))
.attr("src", searchResult.get("logo"))
.attr("alt", `${searchResult.get("title")} logo`);
const logoLink = link.clone().append(logoImg);
logoDiv = $(document.createElement("div"))
.addClass("portal-logo")
.append(logoLink);
} else {
// Create an empty <div>, as no portal image is available.
logoDiv = $(document.createElement("div")).addClass(
"portal-logo",
);
}
const portalTitle = $(document.createElement("h5"))
.addClass("portal-title")
.text(searchResult.get("title"));
const titleLink = link.clone().append(portalTitle);
let descriptionText = searchResult.get("abstract") || "";
const maxLength = window.innerWidth < 800 ? 150 : 300;
if (descriptionText.length > maxLength) {
descriptionText = descriptionText.substr(0, maxLength);
descriptionText = descriptionText.substr(
0,
Math.min(
descriptionText.length,
descriptionText.lastIndexOf(" "),
),
);
descriptionText += "...";
}
const description = $(document.createElement("div"))
.addClass("portal-description")
.append($(document.createElement("p")).text(descriptionText));
const portalInfo = $(document.createElement("div"))
.addClass("portal-info")
.append(titleLink, description);
const editDiv = $(document.createElement("div"))
.addClass("portal-edit-link")
.addClass("controls");
// Add all the elements to the row
listItem.append(logoDiv, portalInfo, editDiv);
// Construct an array of ownership subjects
const wPermission = searchResult.get("writePermission");
const cPermission = searchResult.get("changePermission");
const rightsHolder = searchResult.get("rightsHolder");
let owners = [];
[wPermission, cPermission, rightsHolder].forEach((subjects) => {
if (typeof subjects === "string") {
owners.push(subjects);
} else if (Array.isArray(subjects)) {
owners = owners.concat(subjects);
}
});
// Render an Edit button
if (MetacatUI.appUserModel.hasIdentityOverlap(owners)) {
// Create an Edit buttton
const editButton = $(document.createElement("a"))
.attr(
"href",
`${MetacatUI.root}/edit/${MetacatUI.appModel.get(
"portalTermPlural",
)}/${encodeURIComponent(
searchResult.get("label") ||
searchResult.get("seriesId") ||
searchResult.get("id"),
)}`,
)
.text("Edit")
.addClass("btn edit");
editDiv.append(editButton);
}
}
// Return the list item
return listItem;
} catch (e) {
// eslint-disable-next-line no-console
console.error(e);
return "";
}
},
/**
* Renders a "Create" button for the user to create a new portal
*/
renderCreateButton() {
const container = this.createBtnContainerEl;
if (!container) return;
const authorized = MetacatUI.appUserModel.get(
"isAuthorizedCreatePortal",
);
if (authorized === null) return;
if (authorized !== true) {
container.replaceChildren();
return;
}
const href = `${MetacatUI.root}/edit/${MetacatUI.appModel.get(
"portalTermPlural",
)}`;
if (container.querySelector("a")?.getAttribute("href") === href) return;
const button = document.createElement("a");
button.href = href;
button.classList.add(...CLASS_NAMES.CREATE);
const icon = document.createElement("i");
icon.classList.add(...CLASS_NAMES.ICON);
icon.setAttribute("aria-hidden", "true");
const label = document.createTextNode(
NEW_PORTAL_LABEL + MetacatUI.appModel.get("portalTermSingular"),
);
button.appendChild(icon);
button.appendChild(label);
container.replaceChildren(button);
},
/**
* Cancels the request and removes the pager and list.
* @returns {PortalListView} This view
* @since 2.41.0
*/
remove() {
this.stopListening();
this.searchRequest?.abort();
this.pagerView?.remove();
return Backbone.View.prototype.remove.call(this);
},
/**
* Displays an error message when rendering this view has failed.
*/
showError() {
// Remove the loading elements
this.hideLoading();
if (this.$listContainerEl.children("tr").length === 0) {
// Show an error message
MetacatUI.appView.showAlert(
"Something went wrong while getting this list of portals.",
"alert-error",
this.$listContainerEl,
);
}
},
},
);
return PortalListView;
});