diff --git a/docs/screenshots/views/maps/viewfinder/ViewfinderView.png b/docs/screenshots/views/maps/viewfinder/ViewfinderView.png index 90ffcb42bb..bb4fe60964 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 568ba4b671..0f4bee11b8 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 fc94ac2464..c06e3fd26b 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', diff --git a/src/js/templates/maps/viewfinder/viewfinder.html b/src/js/templates/maps/viewfinder/viewfinder.html index dc7f152848..f5bb5b5523 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 3d2566b173..d13e3d69cb 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 15b1151e92..78e6bb52c0 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 99119b9dc5..6675f7e548 100644 --- a/test/config/tests.json +++ b/test/config/tests.json @@ -8,7 +8,7 @@ "./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/ViewfinderView.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", diff --git a/test/js/specs/shared/create-spy.js b/test/js/specs/shared/create-spy.js deleted file mode 100644 index adb3a4f74e..0000000000 --- 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/views/maps/viewfinder/PredictionsListViewHarness.js b/test/js/specs/unit/views/maps/viewfinder/PredictionsListViewHarness.js index 0a7270aea6..f7b74e66f4 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 83ffd6ba38..c8bdf758a8 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 2114aee52d..0232869c6e 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(); }