diff --git a/src/js/views/maps/MapView.js b/src/js/views/maps/MapView.js index 15a4987cb..5cce070f2 100644 --- a/src/js/views/maps/MapView.js +++ b/src/js/views/maps/MapView.js @@ -7,28 +7,28 @@ define([ "models/maps/Map", "text!templates/maps/map.html", // SubViews - "views/maps/CesiumWidgetView", + "views/maps/MapWidgetContainerView", "views/maps/ToolbarView", "views/maps/ScaleBarView", "views/maps/FeatureInfoView", "views/maps/LayerDetailsView", // CSS - "text!" + MetacatUI.root + "/css/map-view.css", -], function ( + `text!${MetacatUI.root}/css/map-view.css`, +], ( $, _, Backbone, Map, Template, // SubViews - CesiumWidgetView, + MapWidgetContainerView, ToolbarView, ScaleBarView, FeatureInfoView, LayerDetailsView, // CSS MapCSS, -) { +) => { const CLASS_NAMES = { mapWidgetContainer: "map-view__map-widget-container", scaleBarContainer: "map-view__scale-bar-container", @@ -44,12 +44,12 @@ define([ * data. * @classcategory Views/Maps * @name MapView - * @extends Backbone.View + * @augments Backbone.View * @screenshot views/maps/MapView.png * @since 2.18.0 * @constructs */ - var MapView = Backbone.View.extend( + const MapView = Backbone.View.extend( /** @lends MapView.prototype */ { /** * The type of View this is @@ -77,24 +77,21 @@ define([ /** * The events this view will listen to and the associated function to call. - * @type {Object} + * @type {object} */ events: { // 'event selector': 'function', }, /** - * @typedef {Object} ViewfinderViewOptions + * @typedef {object} ViewfinderViewOptions * @property {Map} model The map model that contains the configs for this map view. * @property {boolean} isPortalMap Indicates whether the map view is a part of a * portal, which is styled differently. */ - /** - * Executed when a new MapView is created - * @param {ViewfinderViewOptions} options - */ - initialize: function (options) { + /** @inheritdoc */ + initialize(options) { // Add the CSS required for this view and its sub-views. MetacatUI.appModel.addCSS(MapCSS, "mapView"); @@ -104,80 +101,60 @@ define([ /** * Renders this view - * @return {MapView} Returns the rendered view element + * @returns {MapView} Returns the rendered view element */ - render: function () { - try { - // Save a reference to this view - var view = this; + render() { + // Save a reference to this view + const view = this; - // TODO: Add a nice loading animation? + // TODO: Add a nice loading animation? - // Insert the template into the view - this.$el.html(this.template()); + // Insert the template into the view + this.$el.html(this.template()); - // Ensure the view's main element has the given class name - this.el.classList.add(this.className); - if (this.isPortalMap) { - this.el.classList.add(CLASS_NAMES.portalIndicator); - } - - // Select the elements that will be updatable - this.subElements = {}; - for (const [element, className] of Object.entries(CLASS_NAMES)) { - view.subElements[element] = document.querySelector("." + className); - } + // Ensure the view's main element has the given class name + this.el.classList.add(this.className); + if (this.isPortalMap) { + this.el.classList.add(CLASS_NAMES.portalIndicator); + } - // Render the (Cesium) map - this.renderMapWidget(); + // Select the elements that will be updatable + this.subElements = {}; + Object.entries(CLASS_NAMES).forEach(([element, className]) => { + view.subElements[element] = this.el.querySelector(`.${className}`); + }); - // Optionally add the toolbar, layer details, scale bar, and feature info box. - if (this.model.get("showToolbar")) { - this.renderToolbar(); - this.renderLayerDetails(); - } - if (this.model.get("showScaleBar")) { - this.renderScaleBar(); - } - if ( - this.model.get("showFeatureInfo") & - (this.model.get("clickFeatureAction") === "showDetails") - ) { - this.renderFeatureInfo(); - } + // Render the (Cesium) map + this.renderMapWidget(); - // Return this MapView - return this; - } catch (error) { - console.log( - "There was an error rendering a MapView" + - ". Error details: " + - error, - ); + // Optionally add the toolbar, layer details, scale bar, and feature info box. + if (this.model.get("showToolbar")) { + this.renderToolbar(); + this.renderLayerDetails(); + } + if (this.model.get("showScaleBar")) { + this.renderScaleBar(); + } + if ( + this.model.get("showFeatureInfo") && + this.model.get("clickFeatureAction") === "showDetails" + ) { + this.renderFeatureInfo(); } + return this; }, /** * Renders the view that shows the map/globe and all of the geo-spatial data. - * Currently, this uses the CesiumWidgetView, but this function could be modified - * to use an alternative map widget in the future. - * @returns {CesiumWidgetView} Returns the rendered view + * @returns {MapWidgetContainerView} Returns the rendered view */ - renderMapWidget: function () { - try { - this.mapWidget = new CesiumWidgetView({ - el: this.subElements.mapWidgetContainer, - model: this.model, - }); - this.mapWidget.render(); - return this.mapWidget; - } catch (error) { - console.log( - "There was an error rendering the map widget in a MapView" + - ". Error details: " + - error, - ); - } + renderMapWidget() { + this.mapWidgetContainer = new MapWidgetContainerView({ + el: this.subElements.mapWidgetContainer, + model: this.model, + }); + this.mapWidgetContainer.render(); + return this.mapWidgetContainer; }, /** @@ -185,21 +162,13 @@ define([ * layer list. * @returns {ToolbarView} Returns the rendered view */ - renderToolbar: function () { - try { - this.toolbar = new ToolbarView({ - el: this.subElements.toolbarContainer, - model: this.model, - }); - this.toolbar.render(); - return this.toolbar; - } catch (error) { - console.log( - "There was an error rendering a toolbarView in a MapView" + - ". Error details: " + - error, - ); - } + renderToolbar() { + this.toolbar = new ToolbarView({ + el: this.subElements.toolbarContainer, + model: this.model, + }); + this.toolbar.render(); + return this.toolbar; }, /** @@ -208,33 +177,29 @@ define([ * the first one only. * @returns {FeatureInfoView} Returns the rendered view */ - renderFeatureInfo: function () { - try { - const view = this; - const interactions = view.model.get("interactions"); - const features = view.model.getSelectedFeatures(); - - view.featureInfo = new FeatureInfoView({ - el: view.subElements.featureInfoContainer, - model: features.at(0), - }).render(); - - // When the selectedFeatures collection changes, update the feature - // info view - view.stopListening(features, "update"); - view.listenTo(features, "update", function () { - view.featureInfo.changeModel(features.at(-1)); - }); + renderFeatureInfo() { + const view = this; + const interactions = view.model.get("interactions"); + const features = view.model.getSelectedFeatures(); + + view.featureInfo = new FeatureInfoView({ + el: view.subElements.featureInfoContainer, + model: features.at(0), + }).render(); + + // When the selectedFeatures collection changes, update the feature + // info view + view.stopListening(features, "update"); + view.listenTo(features, "update", () => { + view.featureInfo.changeModel(features.at(-1)); + }); - // If the Feature model is ever completely replaced for any reason, - // make the the Feature Info view gets updated. - const event = "change:selectedFeatures"; - view.stopListening(interactions, event); - view.listenTo(interactions, event, view.renderFeatureInfo); - return view.featureInfo; - } catch (e) { - console.log("Error rendering a FeatureInfoView in a MapView", e); - } + // If the Feature model is ever completely replaced for any reason, + // make the the Feature Info view gets updated. + const event = "change:selectedFeatures"; + view.stopListening(interactions, event); + view.listenTo(interactions, event, view.renderFeatureInfo); + return view.featureInfo; }, /** @@ -242,7 +207,7 @@ define([ * in the toolbar. * @returns {LayerDetailsView} Returns the rendered view */ - renderLayerDetails: function () { + renderLayerDetails() { this.layerDetails = new LayerDetailsView({ el: this.subElements.layerDetailsContainer, }); @@ -251,22 +216,18 @@ define([ // When a layer is selected, show the layer details panel. When a layer is // de-selected, close it. The Layer model's 'selected' attribute gets updated // from the Layer Item View, and also from the Layers collection. - for (const layers of this.model.getLayerGroups()) { + this.model.getLayerGroups().forEach((layers) => { this.stopListening(layers); - this.listenTo( - layers, - "change:selected", - function (layerModel, selected) { - if (selected === false) { - this.layerDetails.updateModel(null); - this.layerDetails.close(); - } else { - this.layerDetails.updateModel(layerModel); - this.layerDetails.open(); - } - }, - ); - } + this.listenTo(layers, "change:selected", (layerModel, selected) => { + if (selected === false) { + this.layerDetails.updateModel(null); + this.layerDetails.close(); + } else { + this.layerDetails.updateModel(layerModel); + this.layerDetails.open(); + } + }); + }); return this.layerDetails; }, @@ -274,47 +235,36 @@ define([ /** * Renders the scale bar view that shows the current position of the mouse on the * map. - * @returns {ScaleBarView} Returns the rendered view */ - renderScaleBar: function () { - try { - const interactions = this.model.get("interactions"); - if (!interactions) { - this.listenToOnce( - this.model, - "change:interactions", - this.renderScaleBar, - ); - return; - } - this.scaleBar = new ScaleBarView({ - el: this.subElements.scaleBarContainer, - scaleModel: interactions.get("scale"), - pointModel: interactions.get("mousePosition"), - }); - this.scaleBar.render(); - - // If the interaction model or relevant sub-models are ever completely - // replaced for any reason, re-render the scale bar. - this.listenToOnce( - interactions, - "change:scale change:mousePosition", - this.renderScaleBar, - ); + renderScaleBar() { + const interactions = this.model.get("interactions"); + if (!interactions) { this.listenToOnce( this.model, "change:interactions", this.renderScaleBar, ); - - return this.scaleBar; - } catch (error) { - console.log( - "There was an error rendering a ScaleBarView in a MapView" + - ". Error details: " + - error, - ); + return; } + this.scaleBar = new ScaleBarView({ + el: this.subElements.scaleBarContainer, + scaleModel: interactions.get("scale"), + pointModel: interactions.get("mousePosition"), + }); + this.scaleBar.render(); + + // If the interaction model or relevant sub-models are ever completely + // replaced for any reason, re-render the scale bar. + this.listenToOnce( + interactions, + "change:scale change:mousePosition", + this.renderScaleBar, + ); + this.listenToOnce( + this.model, + "change:interactions", + this.renderScaleBar, + ); }, /** @@ -323,9 +273,9 @@ define([ * Some may be undefined if they have not been rendered yet. * @since 2.27.0 */ - getSubViews: function () { + getSubViews() { return [ - this.mapWidget, + this.mapWidgetContainer, this.toolbar, this.featureInfo, this.layerDetails, @@ -337,7 +287,7 @@ define([ * Executed when the view is closed. This will close all of the sub-views. * @since 2.27.0 */ - onClose: function () { + onClose() { const subViews = this.getSubViews(); subViews.forEach((subView) => { if (subView && typeof subView.onClose === "function") { diff --git a/src/js/views/maps/MapWidgetContainerView.js b/src/js/views/maps/MapWidgetContainerView.js new file mode 100644 index 000000000..18f6bbc90 --- /dev/null +++ b/src/js/views/maps/MapWidgetContainerView.js @@ -0,0 +1,51 @@ +"use strict"; + +define(["backbone", "models/maps/Map", "views/maps/CesiumWidgetView"], ( + Backbone, + Map, + CesiumWidgetView, +) => { + /** + * @class MapWidgetContainerView + * @classdesc A container for CesiumWidgetView and other map overlays, e.g. lat/lng, legends, etc. + * @classcategory Views/Maps + * @name MapWidgetContainerView + * @augments Backbone.View + * @since 0.0.0 + * @constructs + */ + const MapWidgetContainerView = Backbone.View.extend( + /** @lends MapWidgetContainerView.prototype */ { + /** + * The model that this view uses + * @type {Map} + */ + model: null, + + /** @inheritdoc */ + el: null, + + /** @inheritdoc */ + initialize(options) { + this.el = options.el; + this.model = options.model; + }, + + /** @inheritdoc */ + render() { + this.renderMapWidget(this.el, this.model); + }, + + /** Renders Cesium map. Currently, this uses the MapWidgetContainerView, but this function could be modified to use an alternative map widget in the future. */ + renderMapWidget() { + const mapWidget = new CesiumWidgetView({ + el: this.el, + model: this.model, + }); + mapWidget.render(); + }, + }, + ); + + return MapWidgetContainerView; +}); diff --git a/test/config/tests.json b/test/config/tests.json index c133e5cd7..4cc33fab9 100644 --- a/test/config/tests.json +++ b/test/config/tests.json @@ -67,7 +67,8 @@ "./js/specs/unit/models/Search.spec.js", "./js/specs/unit/models/SolrResult.spec.js", "./js/specs/unit/models/DataONEObject.spec.js", - "./js/specs/unit/views/maps/MapView.spec.js" + "./js/specs/unit/views/maps/MapView.spec.js", + "./js/specs/unit/views/maps/MapWidgetContainerView.spec.js" ], "integration": [ "./js/specs/integration/collections/SolrResults.spec.js", diff --git a/test/js/specs/unit/views/maps/MapView.spec.js b/test/js/specs/unit/views/maps/MapView.spec.js index da586a87b..6ae2565d3 100644 --- a/test/js/specs/unit/views/maps/MapView.spec.js +++ b/test/js/specs/unit/views/maps/MapView.spec.js @@ -12,10 +12,18 @@ define(["views/maps/MapView", "models/maps/Map"], (MapView, MapAsset) => { describe("Portal map", () => { it("has an additional portal indicator class", () => { const nonPortalMap = new MapView(); + // Required for iFrame to not break in FeatureInfoView. + nonPortalMap.$el.hide(); + document.body.appendChild(nonPortalMap.el); + nonPortalMap.render(); expect(nonPortalMap.$el.hasClass("map-view__portal")).to.be.false; const portalMap = new MapView({ isPortalMap: true }); + // Required for iFrame to not break in FeatureInfoView. + portalMap.$el.hide(); + document.body.appendChild(portalMap.el); + portalMap.render(); expect(portalMap.$el.hasClass("map-view__portal")).to.be.true; }); diff --git a/test/js/specs/unit/views/maps/MapWidgetContainerView.spec.js b/test/js/specs/unit/views/maps/MapWidgetContainerView.spec.js new file mode 100644 index 000000000..87fd2610e --- /dev/null +++ b/test/js/specs/unit/views/maps/MapWidgetContainerView.spec.js @@ -0,0 +1,34 @@ +define([ + "views/maps/MapWidgetContainerView", + "models/maps/Map", + "/test/js/specs/shared/clean-state.js", +], (MapWidgetContainerView, Map, cleanState) => { + const expect = chai.expect; + + describe("MapWidgetContainerView Test Suite", () => { + const state = cleanState(() => { + const view = new MapWidgetContainerView({ + el: document.createElement("div"), + model: new Map(), + }); + + return { view }; + }, beforeEach); + + describe("Initialization", () => { + it("creates an MapWidgetContainerView instance", () => { + expect(state.view).to.be.instanceof(MapWidgetContainerView); + }); + }); + + describe("render", () => { + it("adds a Cesium widget to the DOM tree", () => { + state.view.render(); + + expect( + state.view.el.getElementsByClassName("cesium-widget"), + ).to.have.lengthOf(1); + }); + }); + }); +});