diff --git a/docs/screenshots/views/maps/ViewfinderView.png b/docs/screenshots/views/maps/ViewfinderView.png new file mode 100644 index 000000000..90ffcb42b 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 483f7de6c..8a9639a37 100644 --- a/src/css/map-view.css +++ b/src/css/map-view.css @@ -1068,4 +1068,47 @@ other class: .ui-slider-range */ .map-help-panel__section:not(:first-child) { margin-top: 2.5rem; +} + +.viewfinder__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); + } + + .viewfinder__input { + border: none; + box-sizing: border-box; + flex: 1; + height: 100%; + margin: 0; + + &:focus { + border: none; + box-shadow: none; + } + } + + .viewfinder__button { + border: none; + border-radius: 4px; + color: var(--portal-col-buttons); + background: none; + + &:hover { + color: #ffffff; + } + } +} + +.viewfinder__error { + color: var(--map-col-text); + font-size: .8rem; + padding: 4px 12px; } \ No newline at end of file diff --git a/src/js/models/maps/Map.js b/src/js/models/maps/Map.js index c4e4ba7ca..dd62a13fd 100644 --- a/src/js/models/maps/Map.js +++ b/src/js/models/maps/Map.js @@ -46,6 +46,8 @@ define([ * a {@link LayerListView}. * @property {Boolean} [showHomeButton=true] - Whether or not to show the * home button in the toolbar. + * @property {Boolean} [showViewfinder=false] - Whether or not to show the + * viefinder UI and viewfinder button in the toolbar. * @property {Boolean} [toolbarOpen=false] - Whether or not the toolbar is * open when the map is initialized. Set to false by default, so that the * toolbar is hidden by default. @@ -159,6 +161,8 @@ define([ * the layer list in the toolbar. True by default. * @property {Boolean} [showHomeButton=true] - Whether or not to show the * home button in the toolbar. True by default. + * @property {Boolean} [showViewfinder=false] - Whether or not to show the + * viefinder UI and viewfinder button in the toolbar. Defaults to false. * @property {Boolean} [toolbarOpen=false] - Whether or not the toolbar is * open when the map is initialized. Set to false by default, so that the * toolbar is hidden by default. @@ -197,6 +201,7 @@ define([ showToolbar: true, showLayerList: true, showHomeButton: true, + showViewfinder: false, toolbarOpen: false, showScaleBar: true, showFeatureInfo: true, diff --git a/src/js/templates/maps/viewfinder.html b/src/js/templates/maps/viewfinder.html new file mode 100644 index 000000000..dc7f15284 --- /dev/null +++ b/src/js/templates/maps/viewfinder.html @@ -0,0 +1,19 @@ +
+

Viewfinder

+ Enter a latitude and longitude pair and click search 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 d8647d591..3d2566b17 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, ) { /** @@ -151,6 +153,8 @@ define( * This can be provided instead of a view and viewOptions, in which case no * toolbar section will be created. The function will be passed the view and the * Map model as arguments. + * @property {function} [isVisible] A function that determines whether this + * section should be visible in the toolbar. */ /** @@ -167,14 +171,20 @@ define( viewOptions: { model: null, collection: 'model.layers' - } + }, + isVisible(model) { + return model.get("showLayerList"); + }, }, { label: 'Home', icon: 'home', action: function (view, model) { model.flyHome(); - } + }, + isVisible(model) { + return model.get("showHomeButton"); + }, }, // We can enable to the draw tool once we have a use case for it // { @@ -183,6 +193,19 @@ define( // view: DrawTool, // viewOptions: {} // }, + { + label: 'Viewfinder', + icon: 'search', + view: ViewfinderView, + action(view, model) { + const sectionEl = this; + view.defaultActivationAction(sectionEl); + sectionEl.sectionView.focusInput(); + }, + isVisible(model) { + return model.get("showViewfinder"); + }, + }, { label: 'Help', icon: 'question-sign', @@ -191,7 +214,10 @@ define( showFeedback: 'model.showFeedback', feedbackText: 'model.feedbackText', showNavHelp: 'model.showNavHelp', - } + }, + isVisible(model) { + return model.get("showNavHelp") || model.get("showFeedback"); + }, } ], @@ -219,30 +245,16 @@ define( this.model = new Map(); } - if(this.model.get('toolbarOpen') === true) { + if (this.model.get('toolbarOpen') === true) { this.isOpen = true; } - - // Deep clone the section options so that the original array is not - // modified - this.sections = _.map(this.sectionOptions, _.clone); - - if (this.model.get("showLayerList") === false) { - this.sections = this.sections.filter( - (section) => section.label !== "Layers" - ); - } - if (this.model.get("showHomeButton") === false) { - this.sections = this.sections.filter( - (section) => section.label !== "Home" - ); - } - if (!this.model.get("showNavHelp") && !this.model.get("showFeedback")) { - this.sections = this.sections.filter( - (section) => section.label !== "Help" - ); - } + // Check whether each section should be shown, defaulting to true. + this.sections = this.sectionOptions.filter(section => { + return typeof section.isVisible === 'function' + ? section.isVisible(this.model) + : true; + }); } catch (e) { console.log('Error initializing a ToolbarView', e); } @@ -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. */ /** @@ -451,7 +467,8 @@ define( * specified view in that container. * @param {SectionOption} sectionOption The view and view options that are set in * the Section Option are used to create the content container - * @returns {HTMLElement} Returns the content container with the rendered view + * @returns {HTMLElement, Backbone.View} Returns the content container with the rendered view, + * and the Backbone.View itself. */ renderSectionContent: function (sectionOption) { try { @@ -481,7 +498,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 +544,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 +559,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 000000000..15b1151e9 --- /dev/null +++ b/src/js/views/maps/ViewfinderView.js @@ -0,0 +1,184 @@ +"use strict"; + +define([ + "backbone", + "text!templates/maps/viewfinder.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 x.x.x + * @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 HTML elements. + * @type {Object} + */ + classNames: { + baseClass: 'viewfinder', + button: "viewfinder__button", + input: "viewfinder__input", + }, + + /** + * The events this view will listen to and the associated function to call. + * @type {Object} + */ + events() { + return { + [`change .${this.classNames.input}`]: 'valueChange', + [`click .${this.classNames.button}`]: 'search', + [`keyup .${this.classNames.input}`]: 'keyup', + }; + }, + + /** + * Values meant to be used by the rendered HTML template. + */ + templateVars: { + errorMessage: "", + // Track the input value across re-renders. + 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; + this.templateVars.classNames = this.classNames; + }, + + /** + * Render the view by updating the HTML of the element. + * The new HTML is computed from an HTML template that + * is passed an object with relevant view state. + * */ + render() { + this.el.innerHTML = _.template(Template)(this.templateVars); + + this.focusInput(); + }, + + /** + * Helper function to focus input on the searh query input and ensure + * that the cursor is at the end of the text (as opposed to the beginning + * which appears to be the default jQuery behavior). + */ + focusInput() { + const input = this.getInput(); + input.focus(); + // Move cursor to end of input. + input.val(""); + input.val(this.templateVars.inputValue); + }, + + /** + * Getter function for the search query input. + * @return {HTMLInputElement} Returns the search input HTML element. + */ + getInput() { + return this.$el.find(`.${this.classNames.input}`); + }, + + /** + * Getter function for the search button. + * @return {HTMLButtonElement} Returns the search button HTML element. + */ + getButton() { + return this.$el.find(`.${this.classNames.button}`); + }, + + /** + * Event handler for Backbone.View configuration that is called whenever + * the user types a key. + */ + keyup(event) { + if (event.key === "Enter") { + this.search(); + } + }, + + /** + * Event handler for Backbone.View configuration that is called whenever + * the user changes the value in the input field. + */ + valueChange() { + this.templateVars.inputValue = this.getInput().val(); + }, + + /** + * Event handler for Backbone.View configuration that is called whenever + * the user clicks the search button or hits the Enter key. + */ + search() { + this.clearError(); + + const coords = this.parseValue(this.templateVars.inputValue) + if (!coords) return; + + this.model.zoomTo({ ...coords, height: 10000 /* 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 }; + }, + + /** Helper function to clear the error field. */ + clearError() { + this.setError(""); + }, + + /** Helper function to set the error field and re-render the view. */ + 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; \ No newline at end of file diff --git a/test/config/tests.json b/test/config/tests.json index 1fb5afcfa..b70629ad6 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/shared/clean-state.js b/test/js/specs/shared/clean-state.js new file mode 100644 index 000000000..6fb0ca400 --- /dev/null +++ b/test/js/specs/shared/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/shared/create-spy.js b/test/js/specs/shared/create-spy.js new file mode 100644 index 000000000..adb3a4f74 --- /dev/null +++ b/test/js/specs/shared/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 000000000..83ffd6ba3 --- /dev/null +++ b/test/js/specs/unit/views/maps/ViewfinderView.spec.js @@ -0,0 +1,199 @@ +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/shared/clean-state.js", + "/test/js/specs/shared/create-spy.js", +], (ViewfinderView, Map, ViewfinderViewHarness, cleanState, createSpy) => { + const should = chai.should(); + const expect = chai.expect; + + describe("ViewfinderView Test Suite", () => { + const state = cleanState(() => { + const view = new ViewfinderView({ model: new Map() }); + const spy = createSpy(); + view.model.zoomTo = spy; + const harness = new ViewfinderViewHarness(view); + + return { harness, 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(); + + state.harness.typeQuery("123") + + expect(state.view.getInput().val()).to.equal("123"); + }); + + it("zooms to the specified location on clicking search button", () => { + state.view.render(); + + state.harness.typeQuery("13,37") + state.harness.clickSearch(); + + expect(state.spy.callCount).to.equal(1); + }); + + it("zooms to the specified location on hitting 'Enter' key", () => { + state.view.render(); + + state.harness.typeQuery("13,37") + state.harness.hitEnter(); + + expect(state.spy.callCount).to.equal(1); + }); + + it("zooms to the specified location on clicking search button when value is entered without using keyboard", () => { + state.view.render(); + + state.harness.typeQuery("13,37") + state.harness.clickSearch(); + + expect(state.spy.callCount).to.equal(1); + }); + + describe("good search queries", () => { + it("uses the user's search query when zooming", () => { + state.view.render(); + + state.harness.typeQuery("13,37") + state.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(); + + state.harness.typeQuery("13 37") + state.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(); + + state.harness.typeQuery("13,-37") + state.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(); + + state.harness.typeQuery("+13,37") + state.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(); + + state.harness.typeQuery("13") + state.harness.clickSearch(); + + expect(state.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(); + + state.harness.typeQuery("13") + state.harness.clickSearch(); + + expect(state.spy.callCount).to.equal(0); + }); + + it("shows an error when non-numeric characters are entered", () => { + state.view.render(); + + state.harness.typeQuery("13,37a") + state.harness.clickSearch(); + + expect(state.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(); + + state.harness.typeQuery("13,37a") + state.harness.clickSearch(); + + expect(state.spy.callCount).to.equal(0); + }); + + it("shows an error when out of bounds latitude is entered", () => { + state.view.render(); + + state.harness.typeQuery("91,37") + state.harness.clickSearch(); + + expect(state.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(); + + state.harness.typeQuery("91,37") + state.harness.clickSearch(); + + expect(state.spy.callCount).to.equal(1); + }); + + it("shows an error when out of bounds longitude is entered", () => { + state.view.render(); + + state.harness.typeQuery("13,181") + state.harness.clickSearch(); + + expect(state.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(); + + state.harness.typeQuery("13,181") + state.harness.clickSearch(); + + expect(state.spy.callCount).to.equal(1); + }); + + it("clears errors after fixing input error and searching again", () => { + state.view.render(); + + state.harness.typeQuery("13") + state.harness.clickSearch(); + state.harness.typeQuery("13,37") + state.harness.clickSearch(); + + expect(state.harness.hasError()).to.be.false; + }); + + it("zooms to the entered location after fixing input error and searching again", () => { + state.view.render(); + + state.harness.typeQuery("13") + state.harness.clickSearch(); + state.harness.typeQuery("13,37") + state.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 000000000..2114aee52 --- /dev/null +++ b/test/js/specs/unit/views/maps/ViewfinderViewHarness.js @@ -0,0 +1,39 @@ +"use strict"; + +define([], function () { + return class ViewFinderViewHarness { + constructor(view) { + this.view = view; + } + + setQuery(searchString) { + this.view.getInput().val(searchString); + this.view.getInput().trigger("change"); + } + + typeQuery(searchString) { + this.setQuery(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(".viewfinder__error").text(); + } + + getInput() { + return this.view.getInput(); + } + + hasError() { + return this.getError() !== '' + } + } +});