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