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.
-
-
+
+
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();
}