diff --git a/docs/screenshots/views/maps/ViewFinderView.png b/docs/screenshots/views/maps/ViewFinderView.png new file mode 100644 index 0000000000..997a25bb2c Binary files /dev/null and b/docs/screenshots/views/maps/ViewFinderView.png differ diff --git a/src/css/map-view.css b/src/css/map-view.css index 483f7de6c6..a66042f172 100644 --- a/src/css/map-view.css +++ b/src/css/map-view.css @@ -50,7 +50,8 @@ } /* hide the credits until we can find a better placement for them */ -.cesium-widget-credits, .cesium-credit-lightbox-overlay { +.cesium-widget-credits, +.cesium-credit-lightbox-overlay { display: none !important; } @@ -988,7 +989,7 @@ other class: .ui-slider-range */ * */ -.map-help-panel{ +.map-help-panel { width: 100%; } @@ -1057,7 +1058,7 @@ other class: .ui-slider-range */ align-items: center; } -.map-help-panel__title{ +.map-help-panel__title { text-transform: uppercase; font-size: 0.95rem; font-weight: 600; @@ -1068,4 +1069,47 @@ other class: .ui-slider-range */ .map-help-panel__section:not(:first-child) { margin-top: 2.5rem; +} + +.view-finder__field { + border-radius: 4px; + border: 1px solid var(--portal-col-bkg-active); + display: flex; + flex-direction: row; + background: var(--portal-col-bkg-lighter); + + &:focus-within { + border-color: rgba(82, 168, 236, .8); + box-shadow: inset 0 1px 1px rgba(0, 0, 0, .075), 0 0 8px rgba(82, 168, 236, .6); + } + + .view-finder__input { + border: none; + box-sizing: border-box; + flex: 1; + height: 100%; + margin: 0; + + &:focus { + border: none; + box-shadow: none; + } + } + + .view-finder__button { + border: none; + border-radius: 4px; + color: var(--portal-col-buttons); + background: none; + + &:hover { + color: #ffffff; + } + } +} + +.view-finder__error { + color: var(--map-col-text); + font-size: .8rem; + padding: 4px 12px; } \ No newline at end of file diff --git a/src/js/templates/maps/view-finder.html b/src/js/templates/maps/view-finder.html new file mode 100644 index 0000000000..fc5d24e40e --- /dev/null +++ b/src/js/templates/maps/view-finder.html @@ -0,0 +1,19 @@ +
+

View finder

+ Search for a latitude and longitude pair and click the "search" button to show that point on the map. +

+
+
+ + +
+ + <% if(errorMessage) { %> +
+ <%= errorMessage %> +
+ <% } %> +
+
\ No newline at end of file diff --git a/src/js/views/maps/ToolbarView.js b/src/js/views/maps/ToolbarView.js index d8647d5915..b5c4440440 100644 --- a/src/js/views/maps/ToolbarView.js +++ b/src/js/views/maps/ToolbarView.js @@ -10,7 +10,8 @@ define( // Sub-views - TODO: import these as needed 'views/maps/LayerListView', 'views/maps/DrawToolView', - 'views/maps/HelpPanelView' + 'views/maps/HelpPanelView', + 'views/maps/ViewFinderView', ], function ( $, @@ -21,7 +22,8 @@ define( // Sub-views LayerListView, DrawTool, - HelpPanel + HelpPanel, + ViewFinderView, ) { /** @@ -183,6 +185,16 @@ define( // view: DrawTool, // viewOptions: {} // }, + { + label: 'View finder', + icon: 'search', + view: ViewFinderView, + action(view, model) { + const sectionEl = this; + view.defaultActivationAction(sectionEl); + sectionEl.sectionView.focusInput(); + }, + }, { label: 'Help', icon: 'question-sign', @@ -219,7 +231,7 @@ define( this.model = new Map(); } - if(this.model.get('toolbarOpen') === true) { + if (this.model.get('toolbarOpen') === true) { this.isOpen = true; } @@ -286,14 +298,17 @@ define( var linkEl = view.renderSectionLink(sectionOption) var action = sectionOption.action let contentEl = null; + let sectionView; if (sectionOption.view) { - contentEl = view.renderSectionContent(sectionOption) + const { contentContainer, sectionContent } = view.renderSectionContent(sectionOption) + contentEl = contentContainer; + sectionView = sectionContent; } // Set the section to false to start var isActive = false // Save a reference to these elements and their status. sectionEl is an // object that has type SectionElement (documented in comments below) - var sectionEl = { linkEl, contentEl, isActive, action } + var sectionEl = { linkEl, contentEl, isActive, action, sectionView }; view.sectionElements.push(sectionEl) // Attach the link and content to the view if (contentEl) { @@ -336,6 +351,7 @@ define( * section's content, and open/close the toolbar. * @property {Boolean} isActive True if this is the active section, false * otherwise. + * @property {Backbone.View} sectionView The associated Backbone.View instance. */ /** @@ -481,7 +497,7 @@ define( var sectionContent = new sectionOption.view(viewOptions) contentContainer.appendChild(sectionContent.el) sectionContent.render() - return contentContainer + return { contentContainer, sectionContent } } catch (error) { console.log('Error rendering ToolbarView section', error); @@ -527,16 +543,14 @@ define( * @param {SectionElement} sectionEl The section to activate */ activateSection: function (sectionEl) { - if(!sectionEl) return; + if (!sectionEl) return; try { if (sectionEl.action && typeof sectionEl.action === 'function') { const view = this; const model = this.model; sectionEl.action(view, model) } else { - sectionEl.isActive = true; - sectionEl.contentEl.classList.add(this.classes.contentActive) - sectionEl.linkEl.classList.add(this.classes.linkActive) + this.defaultActivationAction(sectionEl); } } catch (error) { @@ -544,6 +558,16 @@ define( } }, + /** + * The default action for a section being activated. + * @param {SectionElement} sectionEl The section to activate + */ + defaultActivationAction(sectionEl) { + sectionEl.isActive = true; + sectionEl.contentEl.classList.add(this.classes.contentActive) + sectionEl.linkEl.classList.add(this.classes.linkActive) + }, + /** * Hide the content of a section * @param {SectionElement} sectionEl The section to inactivate diff --git a/src/js/views/maps/ViewFinderView.js b/src/js/views/maps/ViewFinderView.js new file mode 100644 index 0000000000..f7972daf7d --- /dev/null +++ b/src/js/views/maps/ViewFinderView.js @@ -0,0 +1,155 @@ +"use strict"; + +define([ + "backbone", + "text!templates/maps/view-finder.html", +], ( + Backbone, + Template, +) => { + /** + * @class ViewFinderView + * @classdesc The ViewFinderView allows a user to search for a latitude and longitude in the map view. + * @classcategory Views/Maps + * @name ViewFinderView + * @extends Backbone.View + * @screenshot views/maps/ViewFinderView.png + * @since 2.27.1 + * @constructs ViewFinderView + */ + var ViewFinderView = Backbone.View.extend({ + /** + * The type of View this is + * @type {string} + */ + type: "ViewFinderView", + + /** + * The HTML classes to use for this view's element + * @type {string} + */ + className: classNames.baseClass, + + /** + * The events this view will listen to and the associated function to call. + * @type {Object} + */ + events: { + [`click .${classNames.button}`]: 'search', + [`keyup .${classNames.input}`]: 'keyup', + }, + + /** + * Values meant to be used by the rendered HTML template. + */ + templateVars: { + errorMessage: "", + inputValue: "", + placeholder: "Search by latitude and longitude", + classNames, + }, + + /** + * @typedef {Object} ViewFinderViewOptions + * @property {Map} The Map model associated with this view allowing control + * of panning to different locations on the map. + */ + initialize(options) { + this.mapModel = options.model; + }, + + render() { + this.focusInput(); + + this.el.innerHTML = _.template(Template)(this.templateVars); + }, + + /** + * Focus the input field on a delay to allow for the input to be + * visible on the page before attempting to focus. + */ + focusInput() { + _.defer(() => { + const input = this.getInput(); + input.focus(); + // Move cursor to end of input. + input.val(""); + input.val(this.templateVars.inputValue); + }); + }, + + getInput() { + return this.$el.find(`.${classNames.input}`); + }, + + getButton() { + return this.$el.find(`.${classNames.button}`); + }, + + /** Event handler for Backbone.View configuration. */ + keyup(event) { + this.templateVars.inputValue = this.getInput().val(); + if (event.key === "Enter") { + this.search(); + } + }, + + /** Event handler for Backbone.View configuration. */ + search() { + this.clearError(); + + const coords = this.parseValue(this.templateVars.inputValue) + if (!coords) return; + + this.model.zoomTo({ ...coords, height: 321321 /* meters */ }); + }, + + /** + * Parse the user's input as a pair of floating point numbers. Log errors to the UI + * @return {{Number,Number}|undefined} Undefined represents an irrecoverable user input, + * otherwise returns a latitude, longitude pair. + */ + parseValue(value) { + const matches = value.match(floatsRegex); + const hasBannedChars = value.match(bannedCharactersRegex) != null; + if (matches?.length !== 2 || isNaN(matches[0]) || isNaN(matches[1]) || hasBannedChars) { + this.setError("Try entering a search query with two numerical values representing a latitude and longitude (e.g. 64.84, -147.72)."); + return; + } + + const latitude = Number(matches[0]); + const longitude = Number(matches[1]); + if (latitude > 90 || latitude < -90) { + this.setError("Latitude values outside of the range of -90 to 90 may behave unexpectedly."); + } else if (longitude > 180 || longitude < -180) { + this.setError("Longitude values outside of the range of -180 to 180 may behave unexpectedly."); + } + + return { latitude, longitude }; + }, + + clearError() { + this.setError(""); + }, + + setError(errorMessage) { + this.templateVars.errorMessage = errorMessage; + this.render(); + }, + }); + + return ViewFinderView; +}); + +// Regular expression matching a string that contains two numbers optionally separated by a comma. +const floatsRegex = /[+-]?[0-9]*[.]?[0-9]+/g; + +// Regular expression matching everything except numbers, periods, and commas. +const bannedCharactersRegex = /[^0-9,.+-\s]/g; + +// Class names that correspond to elements in the template. +const classNames = { + baseClass: 'view-finder', + button: "view-finder__button", + input: "view-finder__input", +}; \ No newline at end of file diff --git a/test/config/tests.json b/test/config/tests.json index 1fb5afcfae..8b0a3cc87f 100644 --- a/test/config/tests.json +++ b/test/config/tests.json @@ -1,5 +1,6 @@ { "unit": [ + "./js/specs/unit/views/maps/ViewFinderView.spec.js", "./js/specs/unit/collections/SolrResults.spec.js", "./js/specs/unit/models/Search.spec.js", "./js/specs/unit/models/filters/Filter.spec.js", diff --git a/test/js/specs/clean-state.js b/test/js/specs/clean-state.js new file mode 100644 index 0000000000..6fb0ca400b --- /dev/null +++ b/test/js/specs/clean-state.js @@ -0,0 +1,39 @@ +define([], () => { + /** + * Helper function to prevent state from leaking across test runs. + * @param callback is a function that would typically be passed as a parameter + * to a beforeEach test lifecycle function. In this case we control when it + * is called so that we can return some state from it. + * @param testLifecycleFunction a mocha test lifecycle function like beforeEach + * that will be executed according to the testing framework's rules. + * @return an object containing all of the state of an individual test. + * + * Example usage: + * + * const state = cleanState(() => { + * const someClassInstance = new ClassToBeUsedInTest(); + * + * return { someClassInstance }; + * }, beforeEach); + * + * Now state.someClassInstance can be used with a guarantee that it won't leak + * state from test to test. + */ + return (callback, testLifecycleFunction) => { + const state = {}; + + testLifecycleFunction(() => { + // Delete all properties on state, but don't change the reference. + for (const field in state) { + if (state.hasOwnProperty(field)) { + delete state[field]; + } + } + + // Add new properties to state, but don't change the reference. + Object.assign(state, callback() || {}); + }); + + return state; + } +}); \ No newline at end of file diff --git a/test/js/specs/create-spy.js b/test/js/specs/create-spy.js new file mode 100644 index 0000000000..adb3a4f74e --- /dev/null +++ b/test/js/specs/create-spy.js @@ -0,0 +1,32 @@ +define([], () => { + /** + * Helper function to track calls on a method. + * @return a function that can be substituted for a method, + * which tracks the call count and the call arguments. + * + * Example usage: + * const x = new ClassWithMethods(); + * const spy = createSpy(); + * x.method1 = spy; + * + * x.methodThatCallsMethod1Indirectly(); + * + * expect(spy.callCount).to.equal(1); + * + */ + return () => { + const spy = (...args) => { + spy.callCount++; + spy.callArgs.push(args); + } + + spy.reset = () => { + spy.callCount = 0; + spy.callArgs = []; + } + + spy.reset(); + + return spy; + }; +}); \ No newline at end of file diff --git a/test/js/specs/unit/views/maps/ViewFinderView.spec.js b/test/js/specs/unit/views/maps/ViewFinderView.spec.js new file mode 100644 index 0000000000..11f0dfef35 --- /dev/null +++ b/test/js/specs/unit/views/maps/ViewFinderView.spec.js @@ -0,0 +1,207 @@ +define([ + "views/maps/ViewFinderView", + "models/maps/Map", + // The file extension is required for files loaded from the /test directory. + "/test/js/specs/unit/views/maps/ViewFinderViewHarness.js", + "/test/js/specs/clean-state.js", + "/test/js/specs/create-spy.js", +], (ViewFinderView, Map, ViewFinderViewHarness, cleanState, createSpy) => { + const should = chai.should(); + const expect = chai.expect; + + //TODO(ianguerin):remove this .only call. + describe.only("ViewFinderView Test Suite", () => { + const state = cleanState(() => { + const view = new ViewFinderView({ model: new Map() }); + const spy = createSpy(); + view.model.zoomTo = spy; + + return { spy, view }; + }, beforeEach); + + it("creates a ViewFinderView instance", () => { + state.view.should.be.instanceof(ViewFinderView); + }); + + it("has an input for the user's search query", () => { + state.view.render(); + const harness = new ViewFinderViewHarness(state.view); + + harness.typeQuery("123") + + expect(state.view.getInput().val()).to.equal("123"); + }); + + it("zooms to the specified location on clicking search button", () => { + state.view.render(); + const harness = new ViewFinderViewHarness(state.view); + + harness.typeQuery("13,37") + harness.clickSearch(); + + expect(state.spy.callCount).to.equal(1); + }); + + it("zooms to the specified location on hitting 'Enter' key", () => { + state.view.render(); + const harness = new ViewFinderViewHarness(state.view); + + harness.typeQuery("13,37") + harness.hitEnter(); + + expect(state.spy.callCount).to.equal(1); + }); + + describe("good search queries", () => { + it("uses the user's search query when zooming", () => { + state.view.render(); + const harness = new ViewFinderViewHarness(state.view); + + harness.typeQuery("13,37") + harness.clickSearch(); + + // First argument of the first call. + expect(state.spy.callArgs[0][0]).to.include({ latitude: 13, longitude: 37 }); + }); + + it("accepts user input of two space-separated numbers", () => { + state.view.render(); + const harness = new ViewFinderViewHarness(state.view); + + harness.typeQuery("13 37") + harness.clickSearch(); + + // First argument of the first call. + expect(state.spy.callArgs[0][0]).to.include({ latitude: 13, longitude: 37 }); + }); + + it("accepts user input of with '-' signs", () => { + state.view.render(); + const harness = new ViewFinderViewHarness(state.view); + + harness.typeQuery("13,-37") + harness.clickSearch(); + + // First argument of the first call. + expect(state.spy.callArgs[0][0]).to.include({ latitude: 13, longitude: -37 }); + }); + + it("accepts user input of with '+' signs", () => { + state.view.render(); + const harness = new ViewFinderViewHarness(state.view); + + harness.typeQuery("+13,37") + harness.clickSearch(); + + // First argument of the first call. + expect(state.spy.callArgs[0][0]).to.include({ latitude: 13, longitude: 37 }); + }); + }); + + describe("bad search queries", () => { + it("shows an error when only a single number is entered", () => { + state.view.render(); + const harness = new ViewFinderViewHarness(state.view); + + harness.typeQuery("13") + harness.clickSearch(); + + expect(harness.getError()).to.have.string("Try entering a search query with two numerical values"); + }); + + it("does not try to zoom to location when only a single number is entered", () => { + state.view.render(); + const harness = new ViewFinderViewHarness(state.view); + + harness.typeQuery("13") + harness.clickSearch(); + + expect(state.spy.callCount).to.equal(0); + }); + + it("shows an error when non-numeric characters are entered", () => { + state.view.render(); + const harness = new ViewFinderViewHarness(state.view); + + harness.typeQuery("13,37a") + harness.clickSearch(); + + expect(harness.getError()).to.have.string("Try entering a search query with two numerical values"); + }); + + it("does not try to zoom to location when non-numeric characters are entered", () => { + state.view.render(); + const harness = new ViewFinderViewHarness(state.view); + + harness.typeQuery("13,37a") + harness.clickSearch(); + + expect(state.spy.callCount).to.equal(0); + }); + + it("shows an error when out of bounds latitude is entered", () => { + state.view.render(); + const harness = new ViewFinderViewHarness(state.view); + + harness.typeQuery("91,37") + harness.clickSearch(); + + expect(harness.getError()).to.have.string("Latitude values outside of the"); + }); + + it("still zooms to location when out of bounds latitude is entered", () => { + state.view.render(); + const harness = new ViewFinderViewHarness(state.view); + + harness.typeQuery("91,37") + harness.clickSearch(); + + expect(state.spy.callCount).to.equal(1); + }); + + it("shows an error when out of bounds longitude is entered", () => { + state.view.render(); + const harness = new ViewFinderViewHarness(state.view); + + harness.typeQuery("13,181") + harness.clickSearch(); + + expect(harness.getError()).to.have.string("Longitude values outside of the"); + }); + + it("still zooms to location when out of bounds longitude is entered", () => { + state.view.render(); + const harness = new ViewFinderViewHarness(state.view); + + harness.typeQuery("13,181") + harness.clickSearch(); + + expect(state.spy.callCount).to.equal(1); + }); + + it("clears errors after fixing input error and searching again", () => { + state.view.render(); + const harness = new ViewFinderViewHarness(state.view); + + harness.typeQuery("13") + harness.clickSearch(); + harness.typeQuery("13,37") + harness.clickSearch(); + + expect(harness.hasError()).to.be.false; + }); + + it("zooms to the entered location after fixing input error and searching again", () => { + state.view.render(); + const harness = new ViewFinderViewHarness(state.view); + + harness.typeQuery("13") + harness.clickSearch(); + harness.typeQuery("13,37") + harness.clickSearch(); + + expect(state.spy.callCount).to.equal(1); + }); + }); + }); +}); \ No newline at end of file diff --git a/test/js/specs/unit/views/maps/ViewFinderViewHarness.js b/test/js/specs/unit/views/maps/ViewFinderViewHarness.js new file mode 100644 index 0000000000..44f9986166 --- /dev/null +++ b/test/js/specs/unit/views/maps/ViewFinderViewHarness.js @@ -0,0 +1,34 @@ +"use strict"; + +define([], function () { + return class ViewFinderViewHarness { + constructor(view) { + this.view = view; + } + + typeQuery(searchString) { + this.view.getInput().val(searchString); + this.view.getInput().trigger("keyup"); + } + + clickSearch() { + this.view.getButton().click(); + } + + hitEnter() { + this.view.getInput().trigger({ type: "keyup", key: 'Enter', }); + } + + getError() { + return this.view.$el.find(".view-finder__error").text(); + } + + getInput() { + return this.view.getInput(); + } + + hasError() { + return this.getError() !== '' + } + } +});