diff --git a/docs/screenshots/views/maps/viewfinder/ViewfinderView.png b/docs/screenshots/views/maps/viewfinder/ViewfinderView.png index 90ffcb42b..bb4fe6096 100644 Binary files a/docs/screenshots/views/maps/viewfinder/ViewfinderView.png and b/docs/screenshots/views/maps/viewfinder/ViewfinderView.png differ diff --git a/src/css/map-view.css b/src/css/map-view.css index 568ba4b67..0f4bee11b 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; } @@ -991,7 +992,7 @@ other class: .ui-slider-range */ * */ -.map-help-panel{ +.map-help-panel { width: 100%; } @@ -1060,7 +1061,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; @@ -1073,6 +1074,10 @@ other class: .ui-slider-range */ margin-top: 2.5rem; } +.viewfinder { + width: 100%; +} + .viewfinder__field { border-radius: 4px; border: 1px solid var(--portal-col-bkg-active); @@ -1091,6 +1096,7 @@ other class: .ui-slider-range */ flex: 1; height: 100%; margin: 0; + height: 48px; &:focus { border: none; @@ -1114,4 +1120,46 @@ other class: .ui-slider-range */ color: var(--map-col-text); font-size: .8rem; padding: 4px 12px; +} + +.viewfinder-predictions { + list-style: none; + margin: 0; + + .viewfinder-prediction__content { + align-items: center; + background: var(--map-col-bkg); + border: 1px solid var(--portal-col-bkg-active); + border-radius: 4px; + box-sizing: border-box; + color: var(--map-col-text); + cursor: pointer; + display: flex; + height: 48px; + justify-content: flex-start; + margin: 4px 0; + padding: 12px 8px; + + >* { + flex: 1; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + i { + flex-grow: 0; + min-width: 24px; + max-width: 24px; + text-align: center; + } + + &:hover { + background-color: var(--map-col-bkg-lightest); + } + + &.viewfinder-prediction__focused { + background-color: var(--map-col-bkg-lighter); + } + } } \ No newline at end of file diff --git a/src/js/models/maps/viewfinder/ViewfinderModel.js b/src/js/models/maps/viewfinder/ViewfinderModel.js index 31945d6fa..6b94e8179 100644 --- a/src/js/models/maps/viewfinder/ViewfinderModel.js +++ b/src/js/models/maps/viewfinder/ViewfinderModel.js @@ -1,4 +1,5 @@ 'use strict'; + define( [ 'underscore', @@ -104,39 +105,6 @@ define( this.set('focusIndex', -1); }, - /** - * Event handler for Backbone.View configuration that is called whenever - * the user clicks the search button or hits the Enter key. - * @param {string} value is the query string. - */ - async search(value) { - // This is not a lat,long value, so geocode the prediction instead. - if (!GeoPoint.couldBeLatLong(value)) { - const focusedIndex = Math.max(0, this.get("focusIndex")); - this.selectPrediction(this.get('predictions')[focusedIndex]); - return; - } - - try { - const geoPoint = GeoPoint.fromString(value); - geoPoint.set("height", 10000 /* meters */); - if (geoPoint.isValid()) { - this.set('error', ''); - this.mapModel.zoomTo(geoPoint); - return; - } - - const errors = geoPoint.validationError; - if (errors.latitude) { - this.set('error', errors.latitude); - } else if (errors.longitude) { - this.set('error', errors.longitude); - } - } catch (e) { - this.set('error', e.message); - } - }, - /** * Navigate to the GeocodedLocation. * @param {GeocodedLocation} geocoding is the location that corresponds @@ -164,7 +132,7 @@ define( async selectPrediction(prediction) { if (!prediction) return; - const geocodings = await this.geocoderSearch.searchByPlaceId( + const geocodings = await this.geocoderSearch.geocode( prediction.get('googleMapsPlaceId') ); @@ -176,6 +144,39 @@ define( this.trigger('selection-made', prediction.get('description')); this.goToLocation(geocodings[0]); }, + + /** + * Event handler for Backbone.View configuration that is called whenever + * the user clicks the search button or hits the Enter key. + * @param {string} value is the query string. + */ + async search(value) { + // This is not a lat,long value, so geocode the prediction instead. + if (!GeoPoint.couldBeLatLong(value)) { + const focusedIndex = Math.max(0, this.get("focusIndex")); + this.selectPrediction(this.get('predictions')[focusedIndex]); + return; + } + + try { + const geoPoint = GeoPoint.fromString(value); + geoPoint.set("height", 10000 /* meters */); + if (geoPoint.isValid()) { + this.set('error', ''); + this.mapModel.zoomTo(geoPoint); + return; + } + + const errors = geoPoint.validationError; + if (errors.latitude) { + this.set('error', errors.latitude); + } else if (errors.longitude) { + this.set('error', errors.longitude); + } + } catch (e) { + this.set('error', e.message); + } + }, }); return ViewfinderModel; diff --git a/src/js/templates/maps/viewfinder/viewfinder.html b/src/js/templates/maps/viewfinder/viewfinder.html index dc7f15284..f5bb5b552 100644 --- a/src/js/templates/maps/viewfinder/viewfinder.html +++ b/src/js/templates/maps/viewfinder/viewfinder.html @@ -1,19 +1,15 @@ -
-

Viewfinder

- Enter a latitude and longitude pair and click search to show that point on the map. -

-
-
- - -
+

Viewfinder

+
+
+ + +
- <% if(errorMessage) { %> -
- <%= 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 3d2566b17..d13e3d69c 100644 --- a/src/js/views/maps/ToolbarView.js +++ b/src/js/views/maps/ToolbarView.js @@ -11,7 +11,7 @@ define( 'views/maps/LayerListView', 'views/maps/DrawToolView', 'views/maps/HelpPanelView', - 'views/maps/ViewfinderView', + 'views/maps/viewfinder/ViewfinderView', ], function ( $, diff --git a/src/js/views/maps/viewfinder/ViewfinderView.js b/src/js/views/maps/viewfinder/ViewfinderView.js index 15b1151e9..78e6bb52c 100644 --- a/src/js/views/maps/viewfinder/ViewfinderView.js +++ b/src/js/views/maps/viewfinder/ViewfinderView.js @@ -1,184 +1,240 @@ -"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 */ }); - }, +'use strict'; + +define( + [ + 'backbone', + 'text!templates/maps/viewfinder/viewfinder.html', + 'views/maps/viewfinder/PredictionsListView', + 'models/maps/viewfinder/ViewfinderModel', + ], + (Backbone, Template, PredictionsListView, ViewfinderModel) => { + // The base classname to use for this View's template elements. + const BASE_CLASS = 'viewfinder'; /** - * 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. + * @class ViewfinderView + * @classdesc ViewfinderView allows a user to search for + * a latitude and longitude in the map view, and find suggestions + * for places related to their search terms. + * @classcategory Views/Maps + * @name ViewfinderView + * @extends Backbone.View + * @screenshot views/maps/viewfinder/ViewfinderView.png + * @since x.x.x + * @constructs ViewfinderView */ - 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 + var ViewfinderView = Backbone.View.extend({ + /** + * The type of View this is + * @type {string} + */ + type: 'ViewfinderView', + + /** + * The HTML class to use for this view's outermost element. + * @type {string} + */ + className: BASE_CLASS, + + /** + * The HTML classes to use for this view's HTML elements. + * @type {Object} + */ + classNames: { + button: `${BASE_CLASS}__button`, + input: `${BASE_CLASS}__input`, + predictions: `${BASE_CLASS}__predictions`, + error: `${BASE_CLASS}__error`, + }, + + /** + * The events this view will listen to and the associated function to call. + * @type {Object} + */ + events() { + return { + [`click .${this.classNames.button}`]: 'search', + [`blur .${this.classNames.input}`]: 'hidePredictionsList', + [`change .${this.classNames.input}`]: 'keyup', + [`click .${this.classNames.input}`]: 'showPredictionsList', + [`focus .${this.classNames.input}`]: 'showPredictionsList', + [`keydown .${this.classNames.input}`]: 'keydown', + [`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: 'Enter coordinates or areas of interest', + classNames: {}, + }, + + /** + * @typedef {Object} ViewfinderViewOptions + * @property {Map} The Map model associated with this view allowing control + * of panning to different locations on the map. + */ + initialize({ model: mapModel }) { + this.childPredictionViews = []; + this.templateVars.classNames = this.classNames; + this.viewfinderModel = new ViewfinderModel({ mapModel }); + + this.setupListeners(); + }, + + /** Setup all event listeners on ViewfinderModel. */ + setupListeners() { + this.listenTo(this.viewfinderModel, 'selection-made', (newQuery) => { + this.setQuery(newQuery); + this.getInput().blur(); + }); + + this.listenTo(this.viewfinderModel, 'change:error', () => { + this.setError(this.viewfinderModel.get('error')); + }); + }, + + /** + * 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 error field. + * @return {HTMLDivElement} Returns the error div HTML element. + */ + getError() { + return this.$el.find(`.${this.classNames.error}`); + }, + + /** + * Getter function for the search button. + * @return {HTMLButtonElement} Returns the search button HTML element. + */ + getButton() { + return this.$el.find(`.${this.classNames.button}`); + }, + + /** + * Getter function for the list of predictions. + * @return {HTMLUListElement} Returns the predictions unordered list + * HTML element. + */ + getList() { + return this.$el.find(`.${this.classNames.predictions}`); + }, + + /** + * Event handler to prevent cursor from jumping to beginning + * of an input field (default behavior). + */ + keydown(event) { + if (event.key === 'ArrowUp') { + event.preventDefault(); + } + }, + + /** Trigger the search on the ViewfinderModel. */ + search() { + this.viewfinderModel.search(this.templateVars.inputValue); + }, + + /** + * Event handler for Backbone.View configuration that is called whenever + * the user types a key. + */ + async keyup(event) { + if (event.key === 'Enter') { + this.search(); + } else if (event.key === 'ArrowUp') { + this.viewfinderModel.decrementFocusIndex(); + } else if (event.key === 'ArrowDown') { + this.viewfinderModel.incrementFocusIndex(); + } else { + this.templateVars.inputValue = this.getInput().val(); + this.viewfinderModel.autocompleteSearch(this.templateVars.inputValue); + } + }, + + /** Helper function to set the input field. */ + setQuery(query) { + this.templateVars.inputValue = query; + this.getInput().val(query); + }, + + /** Helper function to set the error field. */ + setError(errorMessage) { + this.templateVars.errorMessage = errorMessage; + this.getError().text(errorMessage); + }, + + /** + * Show the predictions list and potentially submit a search for new + * Predictions to display when there is a search query. + */ + showPredictionsList() { + this.getList().show(); + if (this.viewfinderModel.get('query') !== this.templateVars.inputValue) { + this.viewfinderModel.autocompleteSearch(this.templateVars.inputValue); + } + }, + + /** + * Hide the predictions list unless user is selecting a list item. + * @param {FocusEvent} event Mouse event corresponding to a change in + * focus. + */ + hidePredictionsList(event) { + const clickedInList = this.getList()[0]?.contains(event.relatedTarget); + if (clickedInList) return; + + this.getList().hide(); + }, + + /** + * Render the Prediction sub-views. + */ + renderPredictionsList() { + this.predictionsView = new PredictionsListView({ + viewfinderModel: this.viewfinderModel + }); + this.getList().html(this.predictionsView.el); + this.predictionsView.render(); + }, + + /** + * 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(); + + this.renderPredictionsList(); + }, + }); + + return ViewfinderView; + }); \ No newline at end of file diff --git a/test/config/tests.json b/test/config/tests.json index d7e951254..6675f7e54 100644 --- a/test/config/tests.json +++ b/test/config/tests.json @@ -1,6 +1,14 @@ { "unit": [ - "./js/specs/unit/views/maps/ViewfinderView.spec.js", + "./js/specs/unit/models/geocoder/GeocodedLocation.spec.js", + "./js/specs/unit/models/geocoder/GeocoderSearch.spec.js", + "./js/specs/unit/models/geocoder/GoogleMapsAutocompleter.spec.js", + "./js/specs/unit/models/geocoder/GoogleMapsGeocoder.spec.js", + "./js/specs/unit/models/geocoder/Prediction.spec.js", + "./js/specs/unit/models/maps/viewfinder/ViewfinderModel.spec.js", + "./js/specs/unit/views/maps/viewfinder/PredictionView.spec.js", + "./js/specs/unit/views/maps/viewfinder/PredictionsListView.spec.js", + "./js/specs/unit/views/maps/viewfinder/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", @@ -47,4 +55,4 @@ "./js/specs/integration/collections/SolrResults.spec.js", "./js/specs/integration/models/LookupModel.js" ] -} +} \ No newline at end of file diff --git a/test/js/specs/shared/create-spy.js b/test/js/specs/shared/create-spy.js deleted file mode 100644 index adb3a4f74..000000000 --- a/test/js/specs/shared/create-spy.js +++ /dev/null @@ -1,32 +0,0 @@ -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/models/maps/viewfinder/ViewfinderModel.spec.js b/test/js/specs/unit/models/maps/viewfinder/ViewfinderModel.spec.js new file mode 100644 index 000000000..7a7ed4816 --- /dev/null +++ b/test/js/specs/unit/models/maps/viewfinder/ViewfinderModel.spec.js @@ -0,0 +1,323 @@ +'use strict'; + +define( + [ + 'underscore', + 'models/maps/viewfinder/ViewfinderModel', + 'models/maps/Map', + 'models/geocoder/Prediction', + 'models/geocoder/GeocodedLocation', + // The file extension is required for files loaded from the /test directory. + '/test/js/specs/shared/clean-state.js', + '/test/js/specs/shared/mock-gmaps-module.js', + ], + ( + _, + ViewfinderModel, + Map, + Prediction, + GeocodedLocation, + cleanState, + // Import for side effect, unused. + unusedGmapsMock, + ) => { + const should = chai.should(); + const expect = chai.expect; + + describe('ViewfinderModel Test Suite', () => { + const state = cleanState(() => { + const sandbox = sinon.createSandbox(); + // sinon.useFakeTimers() doesn't work with _.debounce, so stubbing instead. + const debounceStub = sandbox.stub(_, 'debounce').callsFake(function (fnToDebounce) { + return function (...args) { + fnToDebounce.apply(this, args); + }; + }); + const model = new ViewfinderModel({ mapModel: new Map() }); + const zoomSpy = sinon.spy(model.mapModel, 'zoomTo'); + const autocompleteSpy = sandbox.stub(model.geocoderSearch, 'autocomplete').returns([]) + const geocodeSpy = sandbox.stub(model.geocoderSearch, 'geocode').returns([]); + const predictions = [ + new Prediction({ + description: 'Some Location', + googleMapsPlaceId: 'someId', + }), + new Prediction({ + description: 'Some Location 2', + googleMapsPlaceId: 'someId2', + }), + ]; + model.set({ + query: 'somewhere', + error: 'some error', + predictions, + focusIndex: 0, + }); + + return { + autocompleteSpy, + debounceStub, + geocodeSpy, + model, + predictions, + sandbox, + zoomSpy, + }; + }, beforeEach); + + afterEach(() => { + state.sandbox.restore(); + }) + + it('creates a ViewfinderModel instance', () => { + state.model.should.be.instanceof(ViewfinderModel); + }); + + describe('autocomplete search', () => { + it('uses a GeocoderSearch to find autocompletions', () => { + state.model.autocompleteSearch('somewhere else'); + + expect(state.autocompleteSpy.callCount).to.equal(1); + }); + + it('does not autocomplete search if query is unchanged', () => { + state.model.autocompleteSearch('somewhere'); + + expect(state.autocompleteSpy.callCount).to.equal(0); + }); + + it('resets query when query is empty', () => { + state.model.autocompleteSearch(''); + + expect(state.model.get('query')).to.equal(''); + }); + + it('resets error when query is empty', () => { + state.model.autocompleteSearch(''); + + expect(state.model.get('error')).to.equal(''); + }); + + it('resets predictions when query is empty', () => { + state.model.autocompleteSearch(''); + + expect(state.model.get('predictions')).to.deep.equal([]); + }); + + it('resets focus index when query is empty', () => { + state.model.autocompleteSearch(''); + + expect(state.model.get('focusIndex')).to.equal(-1); + }); + + it('resets query when input could be a lat,long pair', () => { + state.model.autocompleteSearch('1,23'); + + expect(state.model.get('query')).to.equal(''); + }); + + it('resets predictions when input could be a lat,long pair', () => { + state.model.autocompleteSearch('1,23'); + + expect(state.model.get('predictions')).to.deep.equal([]); + }); + + it('resets focus index when input could be a lat,long pair', () => { + state.model.autocompleteSearch('1,23'); + + expect(state.model.get('focusIndex')).to.equal(-1); + }); + + it('debounces autocomplete search using underscor\'s debounce', () => { + expect(state.debounceStub.callCount).to.equal(1); + }); + + it('sets predictions from autocomplete search', async () => { + const predictions = [new Prediction({ + description: 'Some Other Location', + googleMapsPlaceId: 'someOtherId', + })]; + state.autocompleteSpy.callsFake(() => predictions); + + state.model.autocompleteSearch("somewhere else"); + // Wait for new predictions to be set on model. + await new Promise(resolve => setTimeout(resolve, 0)); + + expect(state.model.get('predictions').length).to.equal(1); + expect(state.model.get('predictions')[0].get('description')).to.equal('Some Other Location'); + }); + + it('shows \'no results\' message if predictions are empty', async () => { + state.autocompleteSpy.callsFake(() => ([])); + + state.model.autocompleteSearch("somewhere else"); + // Wait for new predictions to be set on model. + await new Promise(resolve => setTimeout(resolve, 0)); + + expect(state.model.get('predictions').length).to.equal(0); + expect(state.model.get('error')).to.match(/No search results/); + }); + }); + + describe('manages the focused prediction index', () => { + it('increments focus index', () => { + state.model.incrementFocusIndex(); + + expect(state.model.get('focusIndex')).to.equal(1); + }); + + it('sets focus index to max if trying to increment beyond max', () => { + state.model.set('focusIndex', 1); + + state.model.incrementFocusIndex(); + + expect(state.model.get('focusIndex')).to.equal(1); + }); + + it('decrements focus index', () => { + state.model.set('focusIndex', 1); + + state.model.decrementFocusIndex(); + + expect(state.model.get('focusIndex')).to.equal(0); + }); + + it('sets focus index to 0 if trying to decrement from -1', () => { + state.model.set('focusIndex', -1); + + state.model.decrementFocusIndex(); + + expect(state.model.get('focusIndex')).to.equal(0); + }); + + it('resets focus index', () => { + state.model.resetFocusIndex(); + + expect(state.model.get('focusIndex')).to.equal(-1); + }); + }); + + + describe('flying to a location on the map', () => { + it('flies to a location on cesium map', () => { + const geocodedLoc = new GeocodedLocation({ + box: { north: 1, south: 2, east: 3, west: 4 } + }); + + state.model.goToLocation(geocodedLoc); + + expect(state.zoomSpy.callCount).to.equal(1); + }); + + it('does nothing if geocoded location is falsy', () => { + state.model.goToLocation(); + + expect(state.zoomSpy.callCount).to.equal(0); + }); + }); + + describe('selecting a prediction', () => { + it('geocodes the selected prediction', async () => { + await state.model.selectPrediction(state.predictions[0]); + + expect(state.geocodeSpy.callCount).to.equal(1); + }); + + it('shows a \'no results\' error message if there are not geocodings', async () => { + await state.model.selectPrediction(state.predictions[0]); + + expect(state.model.get('error')).to.match(/No search results/); + }); + + it('triggers a \'selection-made\' event', async () => { + const triggerSpy = state.sandbox.stub(state.model, 'trigger'); + state.geocodeSpy.returns([ + new GeocodedLocation({ + box: { north: 1, south: 2, east: 3, west: 4 } + }) + ]); + + await state.model.selectPrediction(state.predictions[0]); + + expect(triggerSpy.callCount).to.equal(1); + }); + + it('navigates to the geocoded location', async () => { + state.geocodeSpy.returns([ + new GeocodedLocation({ + box: { north: 1, south: 2, east: 3, west: 4 } + }) + ]); + + await state.model.selectPrediction(state.predictions[0]); + + expect(state.zoomSpy.callCount).to.equal(1); + }); + + it('does nothing if there is not prediction', async () => { + await state.model.selectPrediction(); + + expect(state.geocodeSpy.callCount).to.equal(0); + }); + }); + + describe('searching for a location', () => { + it('geocodes and selects the focused prediction', async () => { + state.geocodeSpy.returns([ + new GeocodedLocation({ + box: { north: 1, south: 2, east: 3, west: 4 } + }) + ]); + + await state.model.search("somewhere"); + + expect(state.zoomSpy.callCount).to.equal(1); + }); + + it('geocodes and selects the first prediction on search if no prediction is focused', async () => { + state.model.set('focusIndex', -1); + state.geocodeSpy.returns([ + new GeocodedLocation({ + box: { north: 1, south: 2, east: 3, west: 4 } + }) + ]); + + await state.model.search("somewhere"); + + expect(state.zoomSpy.callCount).to.equal(1); + }); + + it('zooms to a lat, long pair', async () => { + await state.model.search("45,135"); + + expect(state.zoomSpy.callCount).to.equal(1); + }); + + it('clears errors when entering lat, long pair', async () => { + await state.model.search("45,135"); + + expect(state.model.get('error')).to.equal(''); + }); + + it('sets an error if latitude value is bad', async () => { + await state.model.search("91,135"); + + expect(state.model.get('error')).to.match(/Invalid latitude/); + }); + + it('sets an error if longitude value is bad', async () => { + await state.model.search("45,181"); + + expect(state.model.get('error')).to.match(/Invalid longitude/); + }); + + it('sets an error if search string is not valid as a lat, long pair ', async () => { + await state.model.search("45,"); + + expect(state.model.get('error')).to.match( + /Try entering a search query with two numerical/ + ); + }); + }); + }); + }); \ No newline at end of file diff --git a/test/js/specs/unit/views/maps/viewfinder/PredictionsListViewHarness.js b/test/js/specs/unit/views/maps/viewfinder/PredictionsListViewHarness.js index 0a7270aea..f7b74e66f 100644 --- a/test/js/specs/unit/views/maps/viewfinder/PredictionsListViewHarness.js +++ b/test/js/specs/unit/views/maps/viewfinder/PredictionsListViewHarness.js @@ -9,5 +9,9 @@ define([], function () { getListItems() { return this.view.$el.find('li'); } + + getFocusedItemIndex() { + return this.view.$el.find('.viewfinder-prediction__focused').index(); + } } }); diff --git a/test/js/specs/unit/views/maps/viewfinder/ViewfinderView.spec.js b/test/js/specs/unit/views/maps/viewfinder/ViewfinderView.spec.js index 83ffd6ba3..c8bdf758a 100644 --- a/test/js/specs/unit/views/maps/viewfinder/ViewfinderView.spec.js +++ b/test/js/specs/unit/views/maps/viewfinder/ViewfinderView.spec.js @@ -1,199 +1,221 @@ -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(); +'use strict'; + +define( + [ + 'underscore', + 'views/maps/viewfinder/ViewfinderView', + 'models/maps/Map', + 'models/geocoder/Prediction', + // The file extension is required for files loaded from the /test directory. + '/test/js/specs/unit/views/maps/viewfinder/ViewfinderViewHarness.js', + '/test/js/specs/unit/views/maps/viewfinder/PredictionsListViewHarness.js', + '/test/js/specs/shared/clean-state.js', + '/test/js/specs/shared/mock-gmaps-module.js', + ], + ( + _, + ViewfinderView, + Map, + Prediction, + ViewfinderViewHarness, + PredictionsListViewHarness, + cleanState, + // Import for side effect, unused. + unusedGmapsMock, + ) => { + const should = chai.should(); + const expect = chai.expect; + + // Extract the attributes that tests care about. + const firstCallLatLong = spy => { + const geoPoint = spy.getCall(0).firstArg; + return { + latitude: geoPoint.attributes.latitude, + longitude: geoPoint.attributes.longitude, + }; + }; + + describe('ViewfinderView Test Suite', () => { + const state = cleanState(() => { + const view = new ViewfinderView({ model: new Map() }); + const sandbox = sinon.createSandbox(); + const zoomSpy = sandbox.stub(view.model, 'zoomTo'); + const autocompleteSpy = sandbox.stub(view.viewfinderModel, 'autocompleteSearch'); + const harness = new ViewfinderViewHarness(view); + const predictions = [ + new Prediction({ + description: 'Some Location', + googleMapsPlaceId: 'someId', + }), + new Prediction({ + description: 'Some Location 2', + googleMapsPlaceId: 'someId2', + }), + ]; + view.viewfinderModel.set('predictions', predictions); + + return { harness, autocompleteSpy, zoomSpy, view, sandbox }; + }, beforeEach); + + afterEach(() => { + state.sandbox.restore(); + }); - expect(state.spy.callCount).to.equal(1); - }); + it('creates a ViewfinderView instance', () => { + state.view.should.be.instanceof(ViewfinderView); + }); - it("zooms to the specified location on clicking search button when value is entered without using keyboard", () => { - state.view.render(); + it('has an input for the user\'s search query', () => { + state.view.render(); - state.harness.typeQuery("13,37") - state.harness.clickSearch(); + state.harness.typeQuery('123') - expect(state.spy.callCount).to.equal(1); - }); + expect(state.view.getInput().val()).to.equal('123'); + }); - describe("good search queries", () => { - it("uses the user's search query when zooming", () => { + it('zooms to the specified location on clicking search button', () => { state.view.render(); - state.harness.typeQuery("13,37") + 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 }); + expect(state.zoomSpy.callCount).to.equal(1); }); - it("accepts user input of two space-separated numbers", () => { + it('zooms to the specified location on hitting \'Enter\' key', () => { state.view.render(); - state.harness.typeQuery("13 37") - state.harness.clickSearch(); + state.harness.typeQuery('13,37') + state.harness.hitEnter(); - // First argument of the first call. - expect(state.spy.callArgs[0][0]).to.include({ latitude: 13, longitude: 37 }); + expect(state.zoomSpy.callCount).to.equal(1); }); - it("accepts user input of with '-' signs", () => { + 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.typeQuery('13,37') state.harness.clickSearch(); - // First argument of the first call. - expect(state.spy.callArgs[0][0]).to.include({ latitude: 13, longitude: -37 }); + expect(state.zoomSpy.callCount).to.equal(1); }); - it("accepts user input of with '+' signs", () => { + it('uses the user\'s search query when zooming', () => { state.view.render(); - state.harness.typeQuery("+13,37") + 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 }); + expect(firstCallLatLong(state.zoomSpy)).to.deep.equal({ + latitude: 13, + longitude: 37, + }); }); - }); - describe("bad search queries", () => { - it("shows an error when only a single number is entered", () => { - state.view.render(); + describe('bad search queries', () => { + it('clears errors after fixing input error and searching again', () => { + state.view.render(); - state.harness.typeQuery("13") - state.harness.clickSearch(); + state.harness.typeQuery('13') + state.harness.clickSearch(); + state.harness.typeQuery('13,37') + state.harness.clickSearch(); - expect(state.harness.getError()).to.have.string("Try entering a search query with two numerical values"); - }); + expect(state.harness.hasError()).to.be.false; + }); - it("does not try to zoom to location when only a single number is entered", () => { - state.view.render(); + 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') + state.harness.clickSearch(); + state.harness.typeQuery('13,37') + state.harness.clickSearch(); - expect(state.spy.callCount).to.equal(0); + expect(state.zoomSpy.callCount).to.equal(1); + }); }); - it("shows an error when non-numeric characters are entered", () => { + it('shows an error when a new error is present', () => { + state.view.viewfinderModel.set('error', 'some error'); 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"); + expect(state.harness.getError()).to.match(/some error/); }); - it("does not try to zoom to location when non-numeric characters are entered", () => { + it('initially does not show a autocompletions list', () => { state.view.render(); - state.harness.typeQuery("13,37a") - state.harness.clickSearch(); - - expect(state.spy.callCount).to.equal(0); + expect(state.autocompleteSpy.callCount).to.equal(0); }); - it("shows an error when out of bounds latitude is entered", () => { + it('updates autocompletions when list is shown with updated query string', () => { state.view.render(); + state.view.viewfinderModel.set('query', 'some query'); + state.harness.clickInput(); - state.harness.typeQuery("91,37") - state.harness.clickSearch(); - - expect(state.harness.getError()).to.have.string("Latitude values outside of the"); + expect(state.autocompleteSpy.callCount).to.equal(1); }); - it("still zooms to location when out of bounds latitude is entered", () => { + it('shows no focused item to start', () => { state.view.render(); + const predictionsListHarness = new PredictionsListViewHarness( + state.view.predictionsView + ); - state.harness.typeQuery("91,37") - state.harness.clickSearch(); - - expect(state.spy.callCount).to.equal(1); + expect(predictionsListHarness.getFocusedItemIndex()).to.equal(-1); }); - it("shows an error when out of bounds longitude is entered", () => { - state.view.render(); + describe('as the user types', () => { + it('updates autocompletions as the user types', () => { + state.view.render(); + state.harness.typeQuery('a'); + state.harness.typeQuery('b'); - state.harness.typeQuery("13,181") - state.harness.clickSearch(); + expect(state.autocompleteSpy.callCount).to.equal(2); + }); - expect(state.harness.getError()).to.have.string("Longitude values outside of the"); + it('renders a list of predictions', () => { + state.view.render(); + const predictionsListHarness = new PredictionsListViewHarness( + state.view.predictionsView + ); + + expect(predictionsListHarness.getListItems().length).to.equal(2); + }); }); - it("still zooms to location when out of bounds longitude is entered", () => { - state.view.render(); + describe('arrow key interactions', () => { + it('changes focused element on arrow down', () => { + state.view.render(); + const predictionsListHarness = new PredictionsListViewHarness( + state.view.predictionsView + ); - state.harness.typeQuery("13,181") - state.harness.clickSearch(); + state.harness.hitArrowDown(); - expect(state.spy.callCount).to.equal(1); - }); + expect(predictionsListHarness.getFocusedItemIndex()).to.equal(0); + }); - it("clears errors after fixing input error and searching again", () => { - state.view.render(); + it('changes focused element on arrow up', () => { + state.view.render(); + const predictionsListHarness = new PredictionsListViewHarness( + state.view.predictionsView + ); - state.harness.typeQuery("13") - state.harness.clickSearch(); - state.harness.typeQuery("13,37") - state.harness.clickSearch(); + state.harness.hitArrowUp(); - expect(state.harness.hasError()).to.be.false; + expect(predictionsListHarness.getFocusedItemIndex()).to.equal(0); + }); }); - 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(); + describe('selecting a prediction', () => { + it('updates search query when a selection is made', () => { + state.view.render(); + state.view.viewfinderModel.trigger('selection-made', 'some new query'); - expect(state.spy.callCount).to.equal(1); + expect(state.harness.getInput().val()).to.equal('some new query'); + }); }); }); - }); -}); \ No newline at end of file + }); \ No newline at end of file diff --git a/test/js/specs/unit/views/maps/viewfinder/ViewfinderViewHarness.js b/test/js/specs/unit/views/maps/viewfinder/ViewfinderViewHarness.js index 2114aee52..0232869c6 100644 --- a/test/js/specs/unit/views/maps/viewfinder/ViewfinderViewHarness.js +++ b/test/js/specs/unit/views/maps/viewfinder/ViewfinderViewHarness.js @@ -5,6 +5,10 @@ define([], function () { constructor(view) { this.view = view; } + + clickInput() { + this.view.getInput().click(); + } setQuery(searchString) { this.view.getInput().val(searchString); @@ -12,7 +16,7 @@ define([], function () { } typeQuery(searchString) { - this.setQuery(searchString); + this.view.getInput().val(searchString); this.view.getInput().trigger("keyup"); } @@ -24,6 +28,14 @@ define([], function () { this.view.getInput().trigger({ type: "keyup", key: 'Enter', }); } + hitArrowUp() { + this.view.getInput().trigger({ type: "keyup", key: 'ArrowUp', }); + } + + hitArrowDown() { + this.view.getInput().trigger({ type: "keyup", key: 'ArrowDown', }); + } + getError() { return this.view.$el.find(".viewfinder__error").text(); }