diff --git a/docs/screenshots/views/maps/viewfinder/PredictionView.png b/docs/screenshots/views/maps/viewfinder/PredictionView.png
new file mode 100644
index 000000000..2b724befa
Binary files /dev/null and b/docs/screenshots/views/maps/viewfinder/PredictionView.png differ
diff --git a/docs/screenshots/views/maps/viewfinder/PredictionsListView.png b/docs/screenshots/views/maps/viewfinder/PredictionsListView.png
new file mode 100644
index 000000000..f419be070
Binary files /dev/null and b/docs/screenshots/views/maps/viewfinder/PredictionsListView.png differ
diff --git a/src/js/models/geocoder/Prediction.js b/src/js/models/geocoder/Prediction.js
index 2f4bc2c21..822eb68b8 100644
--- a/src/js/models/geocoder/Prediction.js
+++ b/src/js/models/geocoder/Prediction.js
@@ -9,16 +9,16 @@ define(['backbone'], (Backbone) => {
* @since x.x.x
*/
const Prediction = Backbone.Model.extend({
- /**
- * Overrides the default Backbone.Model.defaults() function to specify
- * default attributes for the Map
- * @name Prediction#defaults
- * @type {Object}
- * @property {string} description A user-friendly description of a Google
- * Maps Place.
- * @property {string} googleMapsPlaceId Unique identifier that can be
- * geocoded by the Google Maps Geocoder API.
- */
+ /**
+ * Overrides the default Backbone.Model.defaults() function to specify
+ * default attributes for the Map
+ * @name Prediction#defaults
+ * @type {Object}
+ * @property {string} description A user-friendly description of a Google
+ * Maps Place.
+ * @property {string} googleMapsPlaceId Unique identifier that can be
+ * geocoded by the Google Maps Geocoder API.
+ */
defaults() {
return { description: '', googleMapsPlaceId: '' };
},
diff --git a/src/js/models/maps/viewfinder/ViewfinderModel.js b/src/js/models/maps/viewfinder/ViewfinderModel.js
new file mode 100644
index 000000000..3d7e438ac
--- /dev/null
+++ b/src/js/models/maps/viewfinder/ViewfinderModel.js
@@ -0,0 +1,173 @@
+'use strict';
+define(
+ [
+ 'underscore',
+ 'backbone',
+ 'cesium',
+ 'models/geocoder/GeocoderSearch',
+ 'models/maps/GeoPoint'],
+ (_, Backbone, Cesium, GeocoderSearch, GeoPoint) => {
+ const NO_RESULTS_MESSAGE = 'No search results found.';
+ /**
+ * @class ViewfinderModel
+ * @classdes ViewfinderModel maintains state for the ViewfinderView and
+ * interfaces with location searching services.
+ * @classcategory Models/Maps
+ */
+ const ViewfinderModel = Backbone.Model.extend({
+ /**
+ * @name ViewfinderModel#defaults
+ * @type {Object}
+ * @property {string} error is the current error string to be displayed
+ * in the UI.
+ * @property {number} focusIndex is the index of the element
+ * in the list of predictions that shoudl be highlighted as focus.
+ * @property {Prediction[]} predictions a list of Predictions models that
+ * correspond to the user's search query.
+ * @property {string} query the user's search query.
+ */
+ defaults() {
+ return {
+ error: '',
+ focusIndex: -1,
+ predictions: [],
+ query: '',
+ }
+ },
+
+ /**
+ * @param {Map} mapModel is the Map model that the ViewfinderModel is
+ * managing for the corresponding ViewfinderView.
+ */
+ initialize({ mapModel }) {
+ this.geocoderSearch = new GeocoderSearch();
+ this.mapModel = mapModel;
+ },
+
+ /**
+ * Get autocompletion predictions from the GeocoderSearch model.
+ * @param {string} rawQuery is the user's search query with spaces.
+ */
+ async autocompleteSearch(rawQuery) {
+ const query = rawQuery.trim();
+ if (this.get('query') === query) {
+ return;
+ } else if (!query) {
+ this.set({ error: '', predictions: [], query: '', focusIndex: -1, });
+ return;
+ } else if (GeoPoint.couldBeLatLong(query)) {
+ this.set({ predictions: [], query: '', focusIndex: -1, });
+ return;
+ }
+
+ // User is looking for autocompletions.
+ const predictions = await this.geocoderSearch.autocomplete(query);
+ const error = predictions.length === 0 ? NO_RESULTS_MESSAGE : '';
+ this.set({ error, focusIndex: -1, predictions, query, });
+ },
+
+ /**
+ * Decrement the focused index with a minimum value of 0. This corresponds
+ * to an ArrowUp key down event.
+ * Note: An ArrowUp key press while the current index is -1 will
+ * result in highlighting the first element in the list.
+ */
+ decrementFocusIndex() {
+ const currentIndex = this.get('focusIndex');
+ this.set('focusIndex', Math.max(0, currentIndex - 1));
+ },
+
+ /**
+ * Increment the focused index with a maximum value of the last value in
+ * the list. This corresponds to an ArrowDown key down event.
+ */
+ incrementFocusIndex() {
+ const currentIndex = this.get('focusIndex');
+ this.set(
+ 'focusIndex',
+ Math.min(currentIndex + 1, this.get('predictions').length - 1)
+ );
+ },
+
+ /**
+ * Reset the focused index back to the initial value so that no element
+ * in the UI is highlighted.
+ */
+ resetFocusIndex() {
+ this.set('focusIndex', -1);
+ },
+
+ /**
+ * Navigate to the GeocodedLocation.
+ * @param {GeocodedLocation} geocoding is the location that corresponds
+ * to the the selected prediction.
+ */
+ goToLocation(geocoding) {
+ if (!geocoding) return;
+
+ const coords = geocoding.get('box').getCoords();
+ this.mapModel.zoomTo({
+ destination: Cesium.Rectangle.fromDegrees(
+ coords.west,
+ coords.south,
+ coords.east,
+ coords.north,
+ )
+ });
+ },
+
+ /**
+ * Select a prediction from the list of predictions and navigate there.
+ * @param {Prediction} prediction is the user-selected Prediction that
+ * needs to be geocoded and navigated to.
+ */
+ async selectPrediction(prediction) {
+ if (!prediction) return;
+
+ const geocodings = await this.geocoderSearch.geocode(prediction);
+
+ if (geocodings.length === 0) {
+ this.set('error', NO_RESULTS_MESSAGE)
+ return;
+ }
+
+ 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 = new GeoPoint(value, { parse: true });
+ 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;
+ });
\ No newline at end of file
diff --git a/src/js/templates/maps/viewfinder/viewfinder-prediction.html b/src/js/templates/maps/viewfinder/viewfinder-prediction.html
new file mode 100644
index 000000000..52715cf37
--- /dev/null
+++ b/src/js/templates/maps/viewfinder/viewfinder-prediction.html
@@ -0,0 +1,9 @@
+
+
+
+ <%= description %>
+
+
\ No newline at end of file
diff --git a/src/js/views/maps/viewfinder/PredictionView.js b/src/js/views/maps/viewfinder/PredictionView.js
new file mode 100644
index 000000000..13892d195
--- /dev/null
+++ b/src/js/views/maps/viewfinder/PredictionView.js
@@ -0,0 +1,113 @@
+'use strict';
+define(
+ ['backbone', 'text!templates/maps/viewfinder/viewfinder-prediction.html'],
+ (Backbone, Template) => {
+ // The base classname to use for this View's template elements.
+ const BASE_CLASS = 'viewfinder-prediction';
+
+ /**
+ * @class PredictionView
+ * @classdesc PredictionView shows an autocomplete suggestion
+ * for the user when they are searching for a place on a map.
+ * @classcategory Views/Maps
+ * @name PredictionView
+ * @extends Backbone.View
+ * @screenshot views/maps/viewfinder/PredictionView.png
+ * @since x.x.x
+ * @constructs PredictionView
+ */
+ const PredictionView = Backbone.View.extend({
+ /**
+ * The type of View this is
+ * @type {string}
+ */
+ type: 'PredictionView',
+
+ /**
+ * The HTML class to use for this view's outermost element.
+ * @type {string}
+ */
+ className: BASE_CLASS,
+
+ /**
+ * The HTML element to use for this view's outermost element.
+ * @type {string}
+ */
+ tagName: 'li',
+
+ /**
+ * The HTML classes to use for this view's HTML elements.
+ * @type {Object}
+ */
+ classNames: {
+ content: `${BASE_CLASS}__content`,
+ },
+
+ /**
+ * The events this view will listen to and the associated function to call.
+ * @type {Object}
+ */
+ events: { click: 'select' },
+
+ /**
+ * Values meant to be used by the rendered HTML template.
+ */
+ templateVars: {
+ classNames: {},
+ isFocused: false,
+ },
+
+ /**
+ * @typedef {Object} PredictionViewOptions
+ * @property {Prediction} The Prediction model associated with this
+ * autocompletion prediction.
+ * @property {ViewfinderModel} The model associated with the parent view.
+ * @property {number} The position of this prediction within the parent's
+ * full list of predictions.
+ */
+ initialize({ index, predictionModel, viewfinderModel }) {
+ this.predictionModel = predictionModel;
+ this.viewfinderModel = viewfinderModel;
+ this.index = index;
+
+ this.templateVars = {
+ ...this.templateVars,
+ classNames: this.classNames,
+ description: this.predictionModel.get('description'),
+ };
+
+ this.setupListeners();
+ },
+
+ /**
+ * Setup all event listeners on ViewfinderModel.
+ */
+ setupListeners() {
+ this.listenTo(this.viewfinderModel, 'change:focusIndex', () => {
+ this.render();
+ });
+ },
+
+ /**
+ * Event handler function that selects this element, deselecting any other
+ * sibling list elements.
+ */
+ select(event) {
+ this.viewfinderModel.selectPrediction(this.predictionModel);
+ },
+
+ /**
+ * 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() {
+ const focusIndex = this.viewfinderModel.get('focusIndex');
+ this.templateVars.isFocused = focusIndex === this.index;
+
+ this.el.innerHTML = _.template(Template)(this.templateVars);
+ },
+ });
+
+ return PredictionView;
+ });
diff --git a/src/js/views/maps/viewfinder/PredictionsListView.js b/src/js/views/maps/viewfinder/PredictionsListView.js
new file mode 100644
index 000000000..32fef4077
--- /dev/null
+++ b/src/js/views/maps/viewfinder/PredictionsListView.js
@@ -0,0 +1,97 @@
+'use strict';
+define(
+ [
+ 'backbone',
+ 'views/maps/viewfinder/PredictionView',
+ 'models/maps/viewfinder/ViewfinderModel',
+ ],
+ (Backbone, PredictionView, ViewfinderModel) => {
+ // The base classname to use for this View's template elements.
+ const BASE_CLASS = 'viewfinder-predictions';
+
+ /**
+ * @class PredictionsListView
+ * @classdesc PredictionsListView manages a list of autocomplete
+ * predictions that can be selected by the user.
+ * @classcategory Views/Maps
+ * @name PredictionsListView
+ * @extends Backbone.View
+ * @screenshot views/maps/viewfinder/PredictionsListView.png
+ * @since x.x.x
+ * @constructs PredictionsListView
+ */
+ var PredictionsListView = Backbone.View.extend({
+ /**
+ * The type of View this is
+ * @type {string}
+ */
+ type: 'PredictionsListView',
+
+ /**
+ * The HTML class to use for this view's outermost element.
+ * @type {string}
+ */
+ className: BASE_CLASS,
+
+ /**
+ * The HTML element to use for this view's outermost element.
+ * @type {string}
+ */
+ tagName: 'ul',
+
+ /**
+ * @typedef {Object} ViewfinderViewOptions
+ * @property {ViewfinderModel} The model associated with the parent view.
+ */
+ initialize({ viewfinderModel }) {
+ this.children = [];
+ this.viewfinderModel = viewfinderModel;
+
+ this.setupListeners();
+ },
+
+ /** Setup all event listeners on ViewfinderModel. */
+ setupListeners() {
+ this.listenTo(this.viewfinderModel, 'change:predictions', () => {
+ this.render();
+ });
+
+ this.listenTo(this.viewfinderModel, 'selection-made', (newQuery) => {
+ if (this.viewfinderModel.get('query') === newQuery) return;
+
+ this.clear();
+ });
+ },
+
+ /**
+ * Remove all child view elements and destroy their Backbone.View.
+ */
+ clear() {
+ while (this.children.length) {
+ this.children.pop().remove();
+ }
+ },
+
+ /**
+ * Render the Prediction sub-views, tracking
+ * them so they can be removed and their event listeners
+ * cleaned up.
+ */
+ render() {
+ this.clear();
+ this.children = this.viewfinderModel.get('predictions').map((prediction, index) => {
+ const view = new PredictionView({
+ index,
+ predictionModel: prediction,
+ viewfinderModel: this.viewfinderModel,
+ });
+ view.render();
+ return view;
+ });
+
+ this.$el.html(this.children.map(view => view.el));
+ },
+ });
+
+ return PredictionsListView;
+ });
\ No newline at end of file
diff --git a/test/config/tests.json b/test/config/tests.json
index d6f60eddb..c92d861b7 100644
--- a/test/config/tests.json
+++ b/test/config/tests.json
@@ -5,6 +5,9 @@
"./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/ViewfinderView.spec.js",
"./js/specs/unit/collections/SolrResults.spec.js",
"./js/specs/unit/models/Search.spec.js",
@@ -58,4 +61,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/unit/models/maps/viewfinder/ViewfinderModel.spec.js b/test/js/specs/unit/models/maps/viewfinder/ViewfinderModel.spec.js
new file mode 100644
index 000000000..7af8e3935
--- /dev/null
+++ b/test/js/specs/unit/models/maps/viewfinder/ViewfinderModel.spec.js
@@ -0,0 +1,302 @@
+'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',
+ ],
+ (_, ViewfinderModel, Map, Prediction, GeocodedLocation, cleanState) => {
+ const should = chai.should();
+ const expect = chai.expect;
+
+ describe('ViewfinderModel Test Suite', () => {
+ const state = cleanState(() => {
+ const sandbox = sinon.createSandbox();
+ 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,
+ 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('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/PredictionView.spec.js b/test/js/specs/unit/views/maps/viewfinder/PredictionView.spec.js
new file mode 100644
index 000000000..9c63f7098
--- /dev/null
+++ b/test/js/specs/unit/views/maps/viewfinder/PredictionView.spec.js
@@ -0,0 +1,81 @@
+'use strict';
+
+define(
+ [
+ 'underscore',
+ 'models/geocoder/Prediction',
+ 'models/maps/Map',
+ 'models/maps/viewfinder/ViewfinderModel',
+ 'views/maps/viewfinder/PredictionView',
+ // The file extension is required for files loaded from the /test directory.
+ '/test/js/specs/unit/views/maps/viewfinder/PredictionViewHarness.js',
+ '/test/js/specs/shared/clean-state.js',
+ ],
+ (
+ _,
+ Prediction,
+ Map,
+ ViewfinderModel,
+ PredictionView,
+ PredictionViewHarness,
+ cleanState,
+ ) => {
+ const should = chai.should();
+ const expect = chai.expect;
+
+ describe('PredictionView Test Suite', () => {
+ const state = cleanState(() => {
+ const sandbox = sinon.createSandbox();
+ const mapModel = new Map();
+ const viewfinderModel = new ViewfinderModel({ mapModel });
+
+ sandbox.stub(viewfinderModel, 'selectPrediction');
+ const predictionModel = new Prediction({
+ description: 'Some Location',
+ googleMapsPlaceId: 'someId',
+ });
+ const index = 0;
+ const view = new PredictionView({
+ index,
+ predictionModel,
+ viewfinderModel,
+ });
+ view.render();
+ const harness = new PredictionViewHarness(view);
+
+ return { harness, view, viewfinderModel };
+ }, beforeEach);
+
+ it('creates a PredictionView instance', () => {
+ state.view.should.be.instanceof(PredictionView);
+ });
+
+ it('renders the prediction\'s description', () => {
+ expect(state.harness.getDescription()).to.match(/Some Location/);
+ });
+
+ it('renders an element that is not focused', () => {
+ expect(state.harness.isFocused()).to.be.false;
+ });
+
+ it('re-renders when the focus index changes', () => {
+ state.viewfinderModel.set('focusIndex', 0);
+
+ expect(state.harness.isFocused()).to.be.true;
+ });
+
+ it('does not show focus styles if index changes to index that does not match', () => {
+ state.viewfinderModel.set('focusIndex', 1);
+
+ expect(state.harness.isFocused()).to.be.false;
+ });
+
+ it('calls a callback on viewfinder model when the element is clicked', () => {
+ state.viewfinderModel.set('focusIndex', 1);
+
+ state.harness.click();
+
+ expect(state.viewfinderModel.selectPrediction.callCount).to.equal(1);
+ });
+ });
+ });
\ No newline at end of file
diff --git a/test/js/specs/unit/views/maps/viewfinder/PredictionViewHarness.js b/test/js/specs/unit/views/maps/viewfinder/PredictionViewHarness.js
new file mode 100644
index 000000000..16493daa5
--- /dev/null
+++ b/test/js/specs/unit/views/maps/viewfinder/PredictionViewHarness.js
@@ -0,0 +1,21 @@
+"use strict";
+
+define([], function () {
+ return class PredictionViewHarness {
+ constructor(view) {
+ this.view = view;
+ }
+
+ click() {
+ this.view.$el.click();
+ }
+
+ isFocused() {
+ return this.view.$el.find('.viewfinder-prediction__focused').length === 1;
+ }
+
+ getDescription() {
+ return this.view.$el.find('.viewfinder-prediction__main').text();
+ }
+ }
+});
diff --git a/test/js/specs/unit/views/maps/viewfinder/PredictionsListView.spec.js b/test/js/specs/unit/views/maps/viewfinder/PredictionsListView.spec.js
new file mode 100644
index 000000000..3a26ab7e8
--- /dev/null
+++ b/test/js/specs/unit/views/maps/viewfinder/PredictionsListView.spec.js
@@ -0,0 +1,92 @@
+'use strict';
+
+define(
+ [
+ 'underscore',
+ 'models/geocoder/Prediction',
+ 'models/maps/Map',
+ 'models/maps/viewfinder/ViewfinderModel',
+ 'views/maps/viewfinder/PredictionsListView',
+ // The file extension is required for files loaded from the /test directory.
+ '/test/js/specs/unit/views/maps/viewfinder/PredictionsListViewHarness.js',
+ '/test/js/specs/shared/clean-state.js',
+ ],
+ (
+ _,
+ Prediction,
+ Map,
+ ViewfinderModel,
+ PredictionsListView,
+ PredictionsListViewHarness,
+ cleanState,
+ ) => {
+ const should = chai.should();
+ const expect = chai.expect;
+
+ describe('PredictionsListView Test Suite', () => {
+ const state = cleanState(() => {
+ const predictions = [
+ new Prediction({
+ description: 'Some Location',
+ googleMapsPlaceId: 'someId',
+ }),
+ new Prediction({
+ description: 'Some Location 2',
+ googleMapsPlaceId: 'someId2',
+ }),
+ ];
+ const mapModel = new Map();
+ const viewfinderModel = new ViewfinderModel({ mapModel });
+ viewfinderModel.set('predictions', predictions)
+ const view = new PredictionsListView({ viewfinderModel });
+ view.render();
+ const harness = new PredictionsListViewHarness(view);
+
+ return { harness, view, viewfinderModel };
+ }, beforeEach);
+
+ it('creates a PredictionsListView instance', () => {
+ state.view.should.be.instanceof(PredictionsListView);
+ });
+
+ it('displays a list item per prediction', () => {
+ expect(state.harness.getListItems().length).to.equal(2);
+ });
+
+ it('re-renders the list when the viewfinder model\'s predictions have changed', () => {
+ const newPredictions = [
+ new Prediction({
+ description: 'Some Location 3',
+ googleMapsPlaceId: 'someId3',
+ }),
+ ];
+ state.viewfinderModel.set('predictions', newPredictions);
+
+ expect(state.harness.getListItems().length).to.equal(1);
+ });
+
+ it('destroys child views when a selection is made', () => {
+ const spy = sinon.spy(state.view.children[0], 'remove');
+ const spy2 = sinon.spy(state.view.children[1], 'remove');
+
+ state.viewfinderModel.trigger('selection-made', 'some query');
+
+ expect(spy.callCount).to.equal(1);
+ expect(spy2.callCount).to.equal(1);
+ });
+
+ it('clears list when a selection is made', () => {
+ state.viewfinderModel.trigger('selection-made', 'some query');
+
+ expect(state.harness.getListItems().length).to.equal(0);
+ });
+
+ it('does not clear the list if the query is unchanged', () => {
+ state.viewfinderModel.set('query', 'some query');
+
+ state.viewfinderModel.trigger('selection-made', 'some query');
+
+ expect(state.harness.getListItems().length).to.equal(2);
+ });
+ });
+ });
\ 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
new file mode 100644
index 000000000..0a7270aea
--- /dev/null
+++ b/test/js/specs/unit/views/maps/viewfinder/PredictionsListViewHarness.js
@@ -0,0 +1,13 @@
+"use strict";
+
+define([], function () {
+ return class PredictionsListViewHarness {
+ constructor(view) {
+ this.view = view;
+ }
+
+ getListItems() {
+ return this.view.$el.find('li');
+ }
+ }
+});