define([
"jquery",
"backbone",
"views/search/SearchResultsView",
"views/filters/FilterGroupsView",
"views/maps/MapView",
"views/search/SearchResultsPagerView",
"views/search/SorterView",
"text!templates/search/catalogSearch.html",
"models/connectors/Map-Search-Filters",
"text!" + MetacatUI.root + "/css/catalog-search-view.css",
], function (
$,
Backbone,
SearchResultsView,
FilterGroupsView,
MapView,
PagerView,
SorterView,
Template,
MapSearchFiltersConnector,
CatalogSearchViewCSS,
) {
"use strict";
/**
* @class CatalogSearchView
* @classdesc The data catalog search view for the repository. This view
* displays a Cesium map, search results, and search filters.
* @name CatalogSearchView
* @classcategory Views
* @extends Backbone.View
* @constructor
* @since 2.22.0
* @screenshot views/search/CatalogSearchView.png
*/
return Backbone.View.extend(
/** @lends CatalogSearchView.prototype */ {
/**
* The type of View this is
* @type {string}
* @since 2.22.0
*/
type: "CatalogSearch",
/**
* The HTML tag to use for this view's element
* @type {string}
* @since 2.22.0
*/
tagName: "section",
/**
* The HTML classes to use for this view's element
* @type {string}
* @since 2.22.0
*/
className: "catalog",
/**
* The template to use for this view's element
* @type {underscore.template}
* @since 2.22.0
*/
template: _.template(Template),
/**
* The template to use in case there is a major error in rendering the
* view.
* @type {string}
* @since 2.25.0
*/
errorTemplate: `<div class="error" role="alert">
<h2>There was an error loading the search results.</h2>
<p>Please try again later.</p>
</div>`,
/**
* Whether the map is displayed or hidden.
* @type boolean
* @since 2.25.0
* @default true
*/
mapVisible: true,
/**
* Whether the filters are displayed or hidden.
* @type boolean
* @since 2.25.0
* @default true
*/
filtersVisible: true,
/**
* Whether to limit the search to the extent of the map. When true, the
* search will update when the user pans or zooms the map. This property
* will be updated when the user clicks the map filter toggle. Whatever is
* set during the initial render will be the default.
* @type {boolean}
* @since 2.25.0
* @default false
*/
limitSearchToMapArea: false,
/**
* Whether to limit the search to the extent the first time the user
* interacts with the map. This only applies if limitSearchToMapArea is
* initially set to false.
* @type {boolean}
* @since 2.26.0
* @default true
*/
limitSearchToMapOnInteraction: true,
/**
* The View that displays the search results. The render method will be
* attach the search results view to the
* {@link CatalogSearchView#searchResultsContainer} element and will add
* the view reference to this property.
* @type {SearchResultsView}
* @since 2.22.0
*/
searchResultsView: null,
/**
* The view that shows the search filters. The render method will attach
* the filter groups view to the
* {@link CatalogSearchView#filterGroupsContainer} element and will add
* the view reference to this property.
* @type {FilterGroupsView}
* @since 2.22.0
*/
filterGroupsView: null,
/**
* The view that shows the number of pages and allows the user to navigate
* between them. The render method will attach the pager view to the
* {@link CatalogSearchView#pagerContainer} element and will add the view
* reference to this property.
* @type {PagerView}
* @since 2.22.0
*/
pagerView: null,
/**
* The view that handles sorting the search results. The render method
* will attach the sorter view to the
* {@link CatalogSearchView#sorterContainer} element and will add the view
* reference to this property.
* @type {SorterView}
* @since 2.22.0
*/
sorterView: null,
/**
* The CSS class to add to the body element when this view is rendered.
* @type {string}
* @since 2.22.0
* @default "catalog-search-body",
*/
bodyClass: "catalog-search-body",
/**
* The jQuery selector for the FilterGroupsView container
* @type {string}
* @since 2.22.0
*/
filterGroupsContainer: ".catalog__filters",
/**
* The query selector for the SearchResultsView container
* @type {string}
* @since 2.22.0
*/
searchResultsContainer: ".catalog__results-list",
/**
* The query selector for the CesiumWidgetView container
* @type {string}
* @since 2.22.0
*/
mapContainer: ".catalog__map",
/**
* The query selector for the PagerView container
* @type {string}
* @since 2.22.0
*/
pagerContainer: ".catalog__pager",
/**
* The query selector for the SorterView container
* @type {string}
* @since 2.22.0
*/
sorterContainer: ".catalog__sorter",
/**
* The query selector for the title container
* @type {string}
* @since 2.22.0
*/
titleContainer: ".catalog__summary",
/**
* The query selector for button that is used to either show or hide the
* map.
* @type {string}
* @since 2.22.0
*/
toggleMapButton: ".catalog__map-toggle",
/**
* The query selector for the label that is used to describe the
* {@link CatalogSearchView#toggleMapButton}.
* @type {string}
* @since 2.25.0
* @default "#toggle-map-label"
*/
toggleMapLabel: "#toggle-map-label",
/**
* The query selector for the button that is used to either show or hide
* the filters.
* @type {string}
* @since 2.25.0
*/
toggleFiltersButton: ".catalog__filters-toggle",
/**
* The query selector for the label that is used to describe the
* {@link CatalogSearchView#toggleFiltersButton}.
* @type {string}
* @since 2.25.0
* @default "#toggle-map-label"
*/
toggleFiltersLabel: "#toggle-filters-label",
/**
* The query selector for the button that is used to turn on or off
* spatial filtering by map extent.
* @type {string}
* @since 2.25.0
*/
mapFilterToggle: ".catalog__map-filter-toggle",
/**
* The CSS class (not selector) to add to the body element when the map is
* hidden.
* @type {string}
* @since 2.25.0
*/
hideMapClass: "catalog--map-hidden",
/**
* The CSS class (not selector) to add to the body element when the
* filters are hidden.
* @type {string}
* @since 2.25.0
*/
hideFiltersClass: "catalog--filters-hidden",
/**
* The events this view will listen to and the associated function to
* call.
* @type {Object}
* @since 2.22.0
*/
events: function () {
const e = {};
e[`click ${this.mapFilterToggle}`] = "toggleMapFilter";
e[`click ${this.toggleMapButton}`] = "toggleMapVisibility";
e[`click ${this.toggleFiltersButton}`] = "toggleFiltersVisibility";
return e;
},
/**
* Initialize the view. In addition to the options described below, any
* option that is available in the
* {@link MapSearchFiltersConnector#initialize} method can be passed to
* this view, such as Map, SolrResult, and FilterGroup models, and whether
* to create a geohash layer or spatial filter if they are not present.
* @param {Object} options - The options for this view.
* @param {string} [options.initialQuery] - The initial text query to run
* when the view is rendered.
* @param {MapSearchFiltersConnector} [options.model] - A
* MapSearchFiltersConnector model to use for this view. If not provided,
* a new one will be created. If one is provided, then other options that
* would be passed to the MapSearchFiltersConnector model will be ignored
* (such as map, searchResults, filterGroups, catalogSearch, etc.)
* @since 2.25.0
*/
initialize: function (options) {
this.cssID = "catalogSearchView";
MetacatUI.appModel.addCSS(CatalogSearchViewCSS, this.cssID);
if (!options) options = {};
this.initialQuery = options.initialQuery || null;
let model = options.model;
if (!model) {
const app = MetacatUI.appModel;
model = new MapSearchFiltersConnector({
map: options.map || app.get("catalogSearchMapOptions"),
searchResults: options.searchResults || null,
filterGroups:
options.filterGroups || app.get("defaultFilterGroups"),
catalogSearch: options.catalogSearch !== false,
addGeohashLayer: options.addGeohashLayer !== false,
addSpatialFilter: options.addSpatialFilter !== false,
});
}
this.model = model;
this.model.connect();
},
/**
* Renders the view
* @since 2.22.0
*/
render: function () {
// Set the search mode - either map or list
this.setMapVisibility();
// Set up the view for styling and layout
this.setupView();
// Render the search components
this.renderComponents();
// Set up the initial map toggle state
this.setMapToggleState();
},
/**
* Indicates that there was a problem rendering this view.
* @since 2.25.0
*/
renderError: function () {
this.$el.html(this.errorTemplate);
},
/**
* Sets the search mode (map or list)
* @since 2.22.0
*/
setMapVisibility: function () {
try {
if (
typeof this.mapVisible === "undefined" &&
MetacatUI.appModel.get("enableCesium")
) {
this.mapVisible = true;
}
// Use map mode on tablets and browsers only. TODO: should we set a
// listener for window resize?
if ($(window).outerWidth() <= 600) {
this.mapVisible = false;
}
} catch (e) {
console.error(
"Error setting the search mode, defaulting to list:" + e,
);
this.mapVisible = false;
}
this.toggleMapVisibility(this.mapVisible);
},
/**
* Sets the initial state of the map filter toggle. Optionally listens
* for the first user interaction with the map before turning on the
* spatial filter.
* @since 2.26.0
*/
setMapToggleState: function () {
// Set the initial state of the spatial filter
this.toggleMapFilter(this.limitSearchToMapArea);
if (this.limitSearchToMapOnInteraction && !this.limitSearchToMapArea) {
this.listenToOnce(
this.model.get("map").get("interactions"),
"change:firstInteraction",
function () {
this.toggleMapFilter(true);
},
);
}
},
/**
* Sets up the basic components of this view
* @since 2.22.0
*/
setupView: function () {
try {
// The body class modifies the entire page layout to accommodate the
// catalog search view
if (!this.isSubView) {
MetacatUI.appModel.set("headerType", "default");
document.querySelector("body").classList.add(this.bodyClass);
} else {
// TODO: Set up styling for sub-view version of the catalog
}
// Add LinkedData to the page
this.addLinkedData();
// Render the template
this.$el.html(
this.template({
mapFilterOn: this.limitSearchToMapArea === true,
}),
);
} catch (e) {
console.log(
"There was an error setting up the CatalogSearchView:" + e,
);
this.renderError();
}
},
/**
* Calls other methods that insert the sub-views into the DOM and render
* them.
* @since 2.22.0
*/
renderComponents: function () {
try {
this.createSearchResults();
this.createMap();
this.renderFilters();
// Render the list of search results
this.renderSearchResults();
// Render the Title
this.renderTitle();
this.listenTo(
this.model.get("searchResults"),
"reset",
this.renderTitle,
);
// Render Pager
this.renderPager();
// Render Sorter
this.renderSorter();
// Render Cesium
this.renderMap();
} catch (e) {
console.log(
"There was an error rendering the CatalogSearchView:" + e,
);
this.renderError();
}
},
/**
* Renders the search filters
* @since 2.22.0
*/
renderFilters: function () {
try {
// Render FilterGroups
this.filterGroupsView = new FilterGroupsView({
filterGroups: this.model.get("filterGroups"),
filters: this.model.get("filters"),
vertical: true,
parentView: this,
initialQuery: this.initialQuery,
collapsible: true,
});
// Add the FilterGroupsView element to this view
this.$(this.filterGroupsContainer).html(this.filterGroupsView.el);
// Render the FilterGroupsView
this.filterGroupsView.render();
} catch (e) {
console.log("There was an error rendering the FilterGroupsView:" + e);
}
},
/**
* Creates the SearchResultsView and saves a reference to the SolrResults
* collection
* @since 2.22.0
*/
createSearchResults: function () {
try {
this.searchResultsView = new SearchResultsView();
this.searchResultsView.searchResults =
this.model.get("searchResults");
} catch (e) {
console.log("There was an error creating the SearchResultsView:" + e);
}
},
/**
* Renders the search result list
* @since 2.22.0
*/
renderSearchResults: function () {
try {
if (!this.searchResultsView) return;
// Add the view element to this view
this.$(this.searchResultsContainer).html(this.searchResultsView.el);
// Render the view
this.searchResultsView.render();
} catch (e) {
console.log(
"There was an error rendering the SearchResultsView:" + e,
);
}
},
/**
* Creates a PagerView and adds it to the page.
* @since 2.22.0
*/
renderPager: function () {
try {
this.pagerView = new PagerView();
// Give the PagerView the SearchResults to listen to and update
this.pagerView.searchResults = this.model.get("searchResults");
// Add the pager view to the page
this.el
.querySelector(this.pagerContainer)
.replaceChildren(this.pagerView.el);
// Render the pager view
this.pagerView.render();
} catch (e) {
console.log("There was an error rendering the PagerView:" + e);
}
},
/**
* Creates a SorterView and adds it to the page.
* @since 2.22.0
*/
renderSorter: function () {
try {
this.sorterView = new SorterView();
// Give the SorterView the SearchResults to listen to and update
this.sorterView.searchResults = this.model.get("searchResults");
// Add the sorter view to the page
this.el
.querySelector(this.sorterContainer)
.replaceChildren(this.sorterView.el);
// Render the sorter view
this.sorterView.render();
} catch (e) {
console.log("There was an error rendering the SorterView:" + e);
}
},
/**
* Constructs an HTML string of the title of this view
* @param {number} start
* @param {number} end
* @param {number} numFound
* @returns {string}
* @since 2.22.0
*/
titleTemplate: function (start, end, numFound) {
try {
let content = "";
const csn = MetacatUI.appView.commaSeparateNumber;
if (numFound < end) end = numFound;
if (numFound > 0) {
content = `<span>${csn(start)}</span> to <span>${csn(end)}</span>`;
if (typeof numFound == "number") {
content += ` of <span>${csn(numFound)}</span>`;
}
}
return `
<div id="statcounts">
<h5 class="result-header-count bold-header" id="countstats">
${content}
</h5>
</div>`;
} catch (e) {
console.log("There was an error creating the title template:" + e);
return "";
}
},
/**
* Updates the view title using the
* {@link CatalogSearchView#searchResults} data.
* @since 2.22.0
*/
renderTitle: function () {
try {
const searchResults = this.model.get("searchResults");
let titleEl = this.el.querySelector(this.titleContainer);
if (!titleEl) {
titleEl = document.createElement("div");
titleEl.classList.add("title-container");
this.el.prepend(titleEl);
}
titleEl.innerHTML = "";
let title = this.titleTemplate(
searchResults.getStart() + 1,
searchResults.getEnd() + 1,
searchResults.getNumFound(),
);
titleEl.insertAdjacentHTML("beforeend", title);
} catch (e) {
console.log("There was an error rendering the title:" + e);
}
},
/**
* Create the models and views associated with the map and map search
* @since 2.22.0
*/
createMap: function () {
try {
this.mapView = new MapView({ model: this.model.get("map") });
} catch (e) {
console.error("Couldn't create map in search. ", e);
this.toggleMapVisibility(false);
}
},
/**
* Renders the Cesium map with a geohash layer
* @since 2.22.0
*/
renderMap: function () {
try {
// Add the map to the page and render it
this.$(this.mapContainer).append(this.mapView.el);
this.mapView.render();
} catch (e) {
console.error("Couldn't render map in search. ", e);
this.toggleMapVisibility(false);
}
},
/**
* Linked Data Object for appending the jsonld into the browser DOM
* @since 2.22.0
*/
addLinkedData: function () {
try {
// JSON Linked Data Object
let elJSON = {
"@context": {
"@vocab": "http://schema.org/",
},
"@type": "DataCatalog",
};
// Find the MN info from the CN Node list
let members = MetacatUI.nodeModel.get("members"),
nodeModelObject;
for (let i = 0; i < members.length; i++) {
if (
members[i].identifier ==
MetacatUI.nodeModel.get("currentMemberNode")
) {
nodeModelObject = members[i];
}
}
if (nodeModelObject) {
// "keywords": "", "provider": "",
let conditionalData = {
description: nodeModelObject.description,
identifier: nodeModelObject.identifier,
image: nodeModelObject.logo,
name: nodeModelObject.name,
url: nodeModelObject.url,
};
$.extend(elJSON, conditionalData);
}
// Check if the jsonld already exists from the previous data view If
// not create a new script tag and append otherwise replace the text
// for the script
if (!document.getElementById("jsonld")) {
var el = document.createElement("script");
el.type = "application/ld+json";
el.id = "jsonld";
el.text = JSON.stringify(elJSON);
document.querySelector("head").appendChild(el);
} else {
var script = document.getElementById("jsonld");
script.text = JSON.stringify(elJSON);
}
} catch (e) {
console.error("Couldn't add linked data to search. ", e);
}
},
/**
* Shows or hide the filters
* @param {boolean} show - Optionally provide the desired choice of
* whether the filters should be shown (true) or hidden (false). If not
* provided, the opposite of the current mode will be used.
* @since 2.25.0
*/
toggleFiltersVisibility: function (show) {
try {
const classList = document.querySelector("body").classList;
// If the new mode is not provided, the new mode is the opposite of
// the current mode
show = typeof show == "boolean" ? show : !this.filtersVisible;
const hideFiltersClass = this.hideFiltersClass;
if (show) {
this.filtersVisible = true;
classList.remove(hideFiltersClass);
} else {
this.filtersVisible = false;
classList.add(hideFiltersClass);
}
this.updateToggleFiltersLabel();
} catch (e) {
console.error("Couldn't toggle filter visibility. ", e);
}
},
/**
* Show or hide the map
* @param {boolean} show - Optionally provide the desired choice of
* whether the filters should be shown (true) or hidden (false). If not
* provided, the opposite of the current mode will be used. (Set to true
* to show map, false to hide it.)
* @since 2.25.0
*/
toggleMapVisibility: function (show) {
try {
// If the new mode is not provided, the new mode is the opposite of
// the current mode
show = typeof show == "boolean" ? show : !this.mapVisible;
const classList = document.querySelector("body").classList;
const hideMapClass = this.hideMapClass;
if (show) {
this.mapVisible = true;
classList.remove(hideMapClass);
} else {
this.mapVisible = false;
classList.add(hideMapClass);
}
this.updateToggleMapLabel();
} catch (e) {
console.error("Couldn't toggle search mode. ", e);
}
},
/**
* Change the content of the map toggle label to indicate whether
* clicking the button will show or hide the map.
* @since 2.25.0
*/
updateToggleMapLabel: function () {
try {
const toggleMapLabel = this.el.querySelector(this.toggleMapLabel);
const toggleMapButton = this.el.querySelector(this.toggleMapButton);
if (this.mapVisible) {
if (toggleMapLabel) {
toggleMapLabel.innerHTML =
'Hide Map <i class="icon icon-angle-right"></i>';
}
if (toggleMapButton) {
toggleMapButton.innerHTML =
'<i class="icon icon-double-angle-right"></i>';
}
} else {
if (toggleMapLabel) {
toggleMapLabel.innerHTML =
'<i class="icon icon-globe"></i> Show Map <i class="icon icon-angle-left"></i>';
}
if (toggleMapButton) {
toggleMapButton.innerHTML = '<i class="icon icon-globe"></i>';
}
}
} catch (e) {
console.log("Couldn't update map toggle. ", e);
}
},
/**
* Change the content of the filters toggle label to indicate whether
* clicking the button will show or hide the filters.
* @since 2.25.0
*/
updateToggleFiltersLabel: function () {
try {
const toggleFiltersLabel = this.el.querySelector(
this.toggleFiltersLabel,
);
const toggleFiltersButton = this.el.querySelector(
this.toggleFiltersButton,
);
if (this.filtersVisible) {
if (toggleFiltersLabel) {
toggleFiltersLabel.innerHTML =
'Hide Filters <i class="icon icon-angle-left"></i>';
}
if (toggleFiltersButton) {
toggleFiltersButton.innerHTML =
'<i class="icon icon-double-angle-left"></i>';
}
} else {
if (toggleFiltersLabel) {
toggleFiltersLabel.innerHTML =
'<i class="icon icon-filter"></i> Show Filters <i class="icon icon-angle-right"></i>';
}
if (toggleFiltersButton) {
toggleFiltersButton.innerHTML =
'<i class="icon icon-filter"></i>';
}
}
} catch (e) {
console.log("Couldn't update filters toggle. ", e);
}
},
/**
* Toggles the map filter on and off
* @param {boolean} newSetting - Optionally provide the desired new mode
* to switch to. true = limit search to map area, false = do not limit
* search to map area. If not provided, the opposite of the current mode
* will be used.
* @since 2.25.0
*/
toggleMapFilter: function (newSetting) {
// Make sure the new setting is a boolean
newSetting =
typeof newSetting != "boolean"
? !this.limitSearchToMapArea // the opposite of the current mode
: newSetting; // the provided new mode if it is a boolean
// Select the map filter toggle checkbox so that we can keep it in sync
// with the new setting
let mapFilterToggle = this.el.querySelector(this.mapFilterToggle);
// If it's not a checkbox input, find the child checkbox input
if (mapFilterToggle && mapFilterToggle.tagName != "INPUT") {
mapFilterToggle = mapFilterToggle.querySelector("input");
}
if (newSetting) {
// If true, then the filter should be ON
this.model.connectFiltersMap();
if (mapFilterToggle) {
mapFilterToggle.checked = true;
}
} else {
// If false, then the filter should be OFF
this.model.disconnectFiltersMap(true);
if (mapFilterToggle) {
mapFilterToggle.checked = false;
}
}
this.limitSearchToMapArea = newSetting;
},
/**
* Tasks to perform when the view is closed
* @since 2.22.0
*/
onClose: function () {
try {
MetacatUI.appModel.removeCSS(this.cssID);
document
.querySelector("body")
.classList.remove(this.bodyClass, this.hideMapClass);
// Remove the JSON-LD from the page
document.getElementById("jsonld")?.remove();
} catch (e) {
console.error("Couldn't close search view. ", e);
}
},
},
);
});