diff --git a/docs/screenshots/views/maps/ViewFinderView.png b/docs/screenshots/views/maps/ViewFinderView.png
new file mode 100644
index 0000000000..997a25bb2c
Binary files /dev/null and b/docs/screenshots/views/maps/ViewFinderView.png differ
diff --git a/src/css/map-view.css b/src/css/map-view.css
index 483f7de6c6..a66042f172 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;
}
@@ -988,7 +989,7 @@ other class: .ui-slider-range */
*
*/
-.map-help-panel{
+.map-help-panel {
width: 100%;
}
@@ -1057,7 +1058,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;
@@ -1068,4 +1069,47 @@ other class: .ui-slider-range */
.map-help-panel__section:not(:first-child) {
margin-top: 2.5rem;
+}
+
+.view-finder__field {
+ border-radius: 4px;
+ border: 1px solid var(--portal-col-bkg-active);
+ display: flex;
+ flex-direction: row;
+ background: var(--portal-col-bkg-lighter);
+
+ &:focus-within {
+ border-color: rgba(82, 168, 236, .8);
+ box-shadow: inset 0 1px 1px rgba(0, 0, 0, .075), 0 0 8px rgba(82, 168, 236, .6);
+ }
+
+ .view-finder__input {
+ border: none;
+ box-sizing: border-box;
+ flex: 1;
+ height: 100%;
+ margin: 0;
+
+ &:focus {
+ border: none;
+ box-shadow: none;
+ }
+ }
+
+ .view-finder__button {
+ border: none;
+ border-radius: 4px;
+ color: var(--portal-col-buttons);
+ background: none;
+
+ &:hover {
+ color: #ffffff;
+ }
+ }
+}
+
+.view-finder__error {
+ color: var(--map-col-text);
+ font-size: .8rem;
+ padding: 4px 12px;
}
\ No newline at end of file
diff --git a/src/js/templates/maps/view-finder.html b/src/js/templates/maps/view-finder.html
new file mode 100644
index 0000000000..fc5d24e40e
--- /dev/null
+++ b/src/js/templates/maps/view-finder.html
@@ -0,0 +1,19 @@
+
+
View finder
+
Search for a latitude and longitude pair and click the "search" button to show that point on the map.
+
+
+
\ No newline at end of file
diff --git a/src/js/views/maps/ToolbarView.js b/src/js/views/maps/ToolbarView.js
index d8647d5915..b5c4440440 100644
--- a/src/js/views/maps/ToolbarView.js
+++ b/src/js/views/maps/ToolbarView.js
@@ -10,7 +10,8 @@ define(
// Sub-views - TODO: import these as needed
'views/maps/LayerListView',
'views/maps/DrawToolView',
- 'views/maps/HelpPanelView'
+ 'views/maps/HelpPanelView',
+ 'views/maps/ViewFinderView',
],
function (
$,
@@ -21,7 +22,8 @@ define(
// Sub-views
LayerListView,
DrawTool,
- HelpPanel
+ HelpPanel,
+ ViewFinderView,
) {
/**
@@ -183,6 +185,16 @@ define(
// view: DrawTool,
// viewOptions: {}
// },
+ {
+ label: 'View finder',
+ icon: 'search',
+ view: ViewFinderView,
+ action(view, model) {
+ const sectionEl = this;
+ view.defaultActivationAction(sectionEl);
+ sectionEl.sectionView.focusInput();
+ },
+ },
{
label: 'Help',
icon: 'question-sign',
@@ -219,7 +231,7 @@ define(
this.model = new Map();
}
- if(this.model.get('toolbarOpen') === true) {
+ if (this.model.get('toolbarOpen') === true) {
this.isOpen = true;
}
@@ -286,14 +298,17 @@ define(
var linkEl = view.renderSectionLink(sectionOption)
var action = sectionOption.action
let contentEl = null;
+ let sectionView;
if (sectionOption.view) {
- contentEl = view.renderSectionContent(sectionOption)
+ const { contentContainer, sectionContent } = view.renderSectionContent(sectionOption)
+ contentEl = contentContainer;
+ sectionView = sectionContent;
}
// Set the section to false to start
var isActive = false
// Save a reference to these elements and their status. sectionEl is an
// object that has type SectionElement (documented in comments below)
- var sectionEl = { linkEl, contentEl, isActive, action }
+ var sectionEl = { linkEl, contentEl, isActive, action, sectionView };
view.sectionElements.push(sectionEl)
// Attach the link and content to the view
if (contentEl) {
@@ -336,6 +351,7 @@ define(
* section's content, and open/close the toolbar.
* @property {Boolean} isActive True if this is the active section, false
* otherwise.
+ * @property {Backbone.View} sectionView The associated Backbone.View instance.
*/
/**
@@ -481,7 +497,7 @@ define(
var sectionContent = new sectionOption.view(viewOptions)
contentContainer.appendChild(sectionContent.el)
sectionContent.render()
- return contentContainer
+ return { contentContainer, sectionContent }
}
catch (error) {
console.log('Error rendering ToolbarView section', error);
@@ -527,16 +543,14 @@ define(
* @param {SectionElement} sectionEl The section to activate
*/
activateSection: function (sectionEl) {
- if(!sectionEl) return;
+ if (!sectionEl) return;
try {
if (sectionEl.action && typeof sectionEl.action === 'function') {
const view = this;
const model = this.model;
sectionEl.action(view, model)
} else {
- sectionEl.isActive = true;
- sectionEl.contentEl.classList.add(this.classes.contentActive)
- sectionEl.linkEl.classList.add(this.classes.linkActive)
+ this.defaultActivationAction(sectionEl);
}
}
catch (error) {
@@ -544,6 +558,16 @@ define(
}
},
+ /**
+ * The default action for a section being activated.
+ * @param {SectionElement} sectionEl The section to activate
+ */
+ defaultActivationAction(sectionEl) {
+ sectionEl.isActive = true;
+ sectionEl.contentEl.classList.add(this.classes.contentActive)
+ sectionEl.linkEl.classList.add(this.classes.linkActive)
+ },
+
/**
* Hide the content of a section
* @param {SectionElement} sectionEl The section to inactivate
diff --git a/src/js/views/maps/ViewFinderView.js b/src/js/views/maps/ViewFinderView.js
new file mode 100644
index 0000000000..f7972daf7d
--- /dev/null
+++ b/src/js/views/maps/ViewFinderView.js
@@ -0,0 +1,155 @@
+"use strict";
+
+define([
+ "backbone",
+ "text!templates/maps/view-finder.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 2.27.1
+ * @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 element
+ * @type {string}
+ */
+ className: classNames.baseClass,
+
+ /**
+ * The events this view will listen to and the associated function to call.
+ * @type {Object}
+ */
+ events: {
+ [`click .${classNames.button}`]: 'search',
+ [`keyup .${classNames.input}`]: 'keyup',
+ },
+
+ /**
+ * Values meant to be used by the rendered HTML template.
+ */
+ templateVars: {
+ errorMessage: "",
+ 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;
+ },
+
+ render() {
+ this.focusInput();
+
+ this.el.innerHTML = _.template(Template)(this.templateVars);
+ },
+
+ /**
+ * Focus the input field on a delay to allow for the input to be
+ * visible on the page before attempting to focus.
+ */
+ focusInput() {
+ _.defer(() => {
+ const input = this.getInput();
+ input.focus();
+ // Move cursor to end of input.
+ input.val("");
+ input.val(this.templateVars.inputValue);
+ });
+ },
+
+ getInput() {
+ return this.$el.find(`.${classNames.input}`);
+ },
+
+ getButton() {
+ return this.$el.find(`.${classNames.button}`);
+ },
+
+ /** Event handler for Backbone.View configuration. */
+ keyup(event) {
+ this.templateVars.inputValue = this.getInput().val();
+ if (event.key === "Enter") {
+ this.search();
+ }
+ },
+
+ /** Event handler for Backbone.View configuration. */
+ search() {
+ this.clearError();
+
+ const coords = this.parseValue(this.templateVars.inputValue)
+ if (!coords) return;
+
+ this.model.zoomTo({ ...coords, height: 321321 /* meters */ });
+ },
+
+ /**
+ * 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.
+ */
+ 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 };
+ },
+
+ clearError() {
+ this.setError("");
+ },
+
+ 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;
+
+// Class names that correspond to elements in the template.
+const classNames = {
+ baseClass: 'view-finder',
+ button: "view-finder__button",
+ input: "view-finder__input",
+};
\ No newline at end of file
diff --git a/test/config/tests.json b/test/config/tests.json
index 1fb5afcfae..8b0a3cc87f 100644
--- a/test/config/tests.json
+++ b/test/config/tests.json
@@ -1,5 +1,6 @@
{
"unit": [
+ "./js/specs/unit/views/maps/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/clean-state.js b/test/js/specs/clean-state.js
new file mode 100644
index 0000000000..6fb0ca400b
--- /dev/null
+++ b/test/js/specs/clean-state.js
@@ -0,0 +1,39 @@
+define([], () => {
+ /**
+ * Helper function to prevent state from leaking across test runs.
+ * @param callback is a function that would typically be passed as a parameter
+ * to a beforeEach test lifecycle function. In this case we control when it
+ * is called so that we can return some state from it.
+ * @param testLifecycleFunction a mocha test lifecycle function like beforeEach
+ * that will be executed according to the testing framework's rules.
+ * @return an object containing all of the state of an individual test.
+ *
+ * Example usage:
+ *
+ * const state = cleanState(() => {
+ * const someClassInstance = new ClassToBeUsedInTest();
+ *
+ * return { someClassInstance };
+ * }, beforeEach);
+ *
+ * Now state.someClassInstance can be used with a guarantee that it won't leak
+ * state from test to test.
+ */
+ return (callback, testLifecycleFunction) => {
+ const state = {};
+
+ testLifecycleFunction(() => {
+ // Delete all properties on state, but don't change the reference.
+ for (const field in state) {
+ if (state.hasOwnProperty(field)) {
+ delete state[field];
+ }
+ }
+
+ // Add new properties to state, but don't change the reference.
+ Object.assign(state, callback() || {});
+ });
+
+ return state;
+ }
+});
\ No newline at end of file
diff --git a/test/js/specs/create-spy.js b/test/js/specs/create-spy.js
new file mode 100644
index 0000000000..adb3a4f74e
--- /dev/null
+++ b/test/js/specs/create-spy.js
@@ -0,0 +1,32 @@
+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/ViewFinderView.spec.js b/test/js/specs/unit/views/maps/ViewFinderView.spec.js
new file mode 100644
index 0000000000..11f0dfef35
--- /dev/null
+++ b/test/js/specs/unit/views/maps/ViewFinderView.spec.js
@@ -0,0 +1,207 @@
+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/clean-state.js",
+ "/test/js/specs/create-spy.js",
+], (ViewFinderView, Map, ViewFinderViewHarness, cleanState, createSpy) => {
+ const should = chai.should();
+ const expect = chai.expect;
+
+ //TODO(ianguerin):remove this .only call.
+ describe.only("ViewFinderView Test Suite", () => {
+ const state = cleanState(() => {
+ const view = new ViewFinderView({ model: new Map() });
+ const spy = createSpy();
+ view.model.zoomTo = spy;
+
+ return { 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();
+ const harness = new ViewFinderViewHarness(state.view);
+
+ harness.typeQuery("123")
+
+ expect(state.view.getInput().val()).to.equal("123");
+ });
+
+ it("zooms to the specified location on clicking search button", () => {
+ state.view.render();
+ const harness = new ViewFinderViewHarness(state.view);
+
+ harness.typeQuery("13,37")
+ harness.clickSearch();
+
+ expect(state.spy.callCount).to.equal(1);
+ });
+
+ it("zooms to the specified location on hitting 'Enter' key", () => {
+ state.view.render();
+ const harness = new ViewFinderViewHarness(state.view);
+
+ harness.typeQuery("13,37")
+ harness.hitEnter();
+
+ expect(state.spy.callCount).to.equal(1);
+ });
+
+ describe("good search queries", () => {
+ it("uses the user's search query when zooming", () => {
+ state.view.render();
+ const harness = new ViewFinderViewHarness(state.view);
+
+ harness.typeQuery("13,37")
+ harness.clickSearch();
+
+ // First argument of the first call.
+ expect(state.spy.callArgs[0][0]).to.include({ latitude: 13, longitude: 37 });
+ });
+
+ it("accepts user input of two space-separated numbers", () => {
+ state.view.render();
+ const harness = new ViewFinderViewHarness(state.view);
+
+ harness.typeQuery("13 37")
+ harness.clickSearch();
+
+ // First argument of the first call.
+ expect(state.spy.callArgs[0][0]).to.include({ latitude: 13, longitude: 37 });
+ });
+
+ it("accepts user input of with '-' signs", () => {
+ state.view.render();
+ const harness = new ViewFinderViewHarness(state.view);
+
+ harness.typeQuery("13,-37")
+ harness.clickSearch();
+
+ // First argument of the first call.
+ expect(state.spy.callArgs[0][0]).to.include({ latitude: 13, longitude: -37 });
+ });
+
+ it("accepts user input of with '+' signs", () => {
+ state.view.render();
+ const harness = new ViewFinderViewHarness(state.view);
+
+ harness.typeQuery("+13,37")
+ harness.clickSearch();
+
+ // First argument of the first call.
+ expect(state.spy.callArgs[0][0]).to.include({ latitude: 13, longitude: 37 });
+ });
+ });
+
+ describe("bad search queries", () => {
+ it("shows an error when only a single number is entered", () => {
+ state.view.render();
+ const harness = new ViewFinderViewHarness(state.view);
+
+ harness.typeQuery("13")
+ harness.clickSearch();
+
+ expect(harness.getError()).to.have.string("Try entering a search query with two numerical values");
+ });
+
+ it("does not try to zoom to location when only a single number is entered", () => {
+ state.view.render();
+ const harness = new ViewFinderViewHarness(state.view);
+
+ harness.typeQuery("13")
+ harness.clickSearch();
+
+ expect(state.spy.callCount).to.equal(0);
+ });
+
+ it("shows an error when non-numeric characters are entered", () => {
+ state.view.render();
+ const harness = new ViewFinderViewHarness(state.view);
+
+ harness.typeQuery("13,37a")
+ harness.clickSearch();
+
+ expect(harness.getError()).to.have.string("Try entering a search query with two numerical values");
+ });
+
+ it("does not try to zoom to location when non-numeric characters are entered", () => {
+ state.view.render();
+ const harness = new ViewFinderViewHarness(state.view);
+
+ harness.typeQuery("13,37a")
+ harness.clickSearch();
+
+ expect(state.spy.callCount).to.equal(0);
+ });
+
+ it("shows an error when out of bounds latitude is entered", () => {
+ state.view.render();
+ const harness = new ViewFinderViewHarness(state.view);
+
+ harness.typeQuery("91,37")
+ harness.clickSearch();
+
+ expect(harness.getError()).to.have.string("Latitude values outside of the");
+ });
+
+ it("still zooms to location when out of bounds latitude is entered", () => {
+ state.view.render();
+ const harness = new ViewFinderViewHarness(state.view);
+
+ harness.typeQuery("91,37")
+ harness.clickSearch();
+
+ expect(state.spy.callCount).to.equal(1);
+ });
+
+ it("shows an error when out of bounds longitude is entered", () => {
+ state.view.render();
+ const harness = new ViewFinderViewHarness(state.view);
+
+ harness.typeQuery("13,181")
+ harness.clickSearch();
+
+ expect(harness.getError()).to.have.string("Longitude values outside of the");
+ });
+
+ it("still zooms to location when out of bounds longitude is entered", () => {
+ state.view.render();
+ const harness = new ViewFinderViewHarness(state.view);
+
+ harness.typeQuery("13,181")
+ harness.clickSearch();
+
+ expect(state.spy.callCount).to.equal(1);
+ });
+
+ it("clears errors after fixing input error and searching again", () => {
+ state.view.render();
+ const harness = new ViewFinderViewHarness(state.view);
+
+ harness.typeQuery("13")
+ harness.clickSearch();
+ harness.typeQuery("13,37")
+ harness.clickSearch();
+
+ expect(harness.hasError()).to.be.false;
+ });
+
+ it("zooms to the entered location after fixing input error and searching again", () => {
+ state.view.render();
+ const harness = new ViewFinderViewHarness(state.view);
+
+ harness.typeQuery("13")
+ harness.clickSearch();
+ harness.typeQuery("13,37")
+ harness.clickSearch();
+
+ expect(state.spy.callCount).to.equal(1);
+ });
+ });
+ });
+});
\ No newline at end of file
diff --git a/test/js/specs/unit/views/maps/ViewFinderViewHarness.js b/test/js/specs/unit/views/maps/ViewFinderViewHarness.js
new file mode 100644
index 0000000000..44f9986166
--- /dev/null
+++ b/test/js/specs/unit/views/maps/ViewFinderViewHarness.js
@@ -0,0 +1,34 @@
+"use strict";
+
+define([], function () {
+ return class ViewFinderViewHarness {
+ constructor(view) {
+ this.view = view;
+ }
+
+ typeQuery(searchString) {
+ this.view.getInput().val(searchString);
+ this.view.getInput().trigger("keyup");
+ }
+
+ clickSearch() {
+ this.view.getButton().click();
+ }
+
+ hitEnter() {
+ this.view.getInput().trigger({ type: "keyup", key: 'Enter', });
+ }
+
+ getError() {
+ return this.view.$el.find(".view-finder__error").text();
+ }
+
+ getInput() {
+ return this.view.getInput();
+ }
+
+ hasError() {
+ return this.getError() !== ''
+ }
+ }
+});