diff --git a/docs/screenshots/views/maps/ViewfinderView.png b/docs/screenshots/views/maps/ViewfinderView.png
new file mode 100644
index 000000000..90ffcb42b
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 483f7de6c..8a9639a37 100644
--- a/src/css/map-view.css
+++ b/src/css/map-view.css
@@ -1068,4 +1068,47 @@ other class: .ui-slider-range */
.map-help-panel__section:not(:first-child) {
margin-top: 2.5rem;
+}
+
+.viewfinder__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);
+ }
+
+ .viewfinder__input {
+ border: none;
+ box-sizing: border-box;
+ flex: 1;
+ height: 100%;
+ margin: 0;
+
+ &:focus {
+ border: none;
+ box-shadow: none;
+ }
+ }
+
+ .viewfinder__button {
+ border: none;
+ border-radius: 4px;
+ color: var(--portal-col-buttons);
+ background: none;
+
+ &:hover {
+ color: #ffffff;
+ }
+ }
+}
+
+.viewfinder__error {
+ color: var(--map-col-text);
+ font-size: .8rem;
+ padding: 4px 12px;
}
\ No newline at end of file
diff --git a/src/js/models/maps/Map.js b/src/js/models/maps/Map.js
index c4e4ba7ca..dd62a13fd 100644
--- a/src/js/models/maps/Map.js
+++ b/src/js/models/maps/Map.js
@@ -46,6 +46,8 @@ define([
* a {@link LayerListView}.
* @property {Boolean} [showHomeButton=true] - Whether or not to show the
* home button in the toolbar.
+ * @property {Boolean} [showViewfinder=false] - Whether or not to show the
+ * viefinder UI and viewfinder button in the toolbar.
* @property {Boolean} [toolbarOpen=false] - Whether or not the toolbar is
* open when the map is initialized. Set to false by default, so that the
* toolbar is hidden by default.
@@ -159,6 +161,8 @@ define([
* the layer list in the toolbar. True by default.
* @property {Boolean} [showHomeButton=true] - Whether or not to show the
* home button in the toolbar. True by default.
+ * @property {Boolean} [showViewfinder=false] - Whether or not to show the
+ * viefinder UI and viewfinder button in the toolbar. Defaults to false.
* @property {Boolean} [toolbarOpen=false] - Whether or not the toolbar is
* open when the map is initialized. Set to false by default, so that the
* toolbar is hidden by default.
@@ -197,6 +201,7 @@ define([
showToolbar: true,
showLayerList: true,
showHomeButton: true,
+ showViewfinder: false,
toolbarOpen: false,
showScaleBar: true,
showFeatureInfo: true,
diff --git a/src/js/templates/maps/viewfinder.html b/src/js/templates/maps/viewfinder.html
new file mode 100644
index 000000000..dc7f15284
--- /dev/null
+++ b/src/js/templates/maps/viewfinder.html
@@ -0,0 +1,19 @@
+
+
Viewfinder
+
Enter a latitude and longitude pair and click search 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 d8647d591..3d2566b17 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,
) {
/**
@@ -151,6 +153,8 @@ define(
* This can be provided instead of a view and viewOptions, in which case no
* toolbar section will be created. The function will be passed the view and the
* Map model as arguments.
+ * @property {function} [isVisible] A function that determines whether this
+ * section should be visible in the toolbar.
*/
/**
@@ -167,14 +171,20 @@ define(
viewOptions: {
model: null,
collection: 'model.layers'
- }
+ },
+ isVisible(model) {
+ return model.get("showLayerList");
+ },
},
{
label: 'Home',
icon: 'home',
action: function (view, model) {
model.flyHome();
- }
+ },
+ isVisible(model) {
+ return model.get("showHomeButton");
+ },
},
// We can enable to the draw tool once we have a use case for it
// {
@@ -183,6 +193,19 @@ define(
// view: DrawTool,
// viewOptions: {}
// },
+ {
+ label: 'Viewfinder',
+ icon: 'search',
+ view: ViewfinderView,
+ action(view, model) {
+ const sectionEl = this;
+ view.defaultActivationAction(sectionEl);
+ sectionEl.sectionView.focusInput();
+ },
+ isVisible(model) {
+ return model.get("showViewfinder");
+ },
+ },
{
label: 'Help',
icon: 'question-sign',
@@ -191,7 +214,10 @@ define(
showFeedback: 'model.showFeedback',
feedbackText: 'model.feedbackText',
showNavHelp: 'model.showNavHelp',
- }
+ },
+ isVisible(model) {
+ return model.get("showNavHelp") || model.get("showFeedback");
+ },
}
],
@@ -219,30 +245,16 @@ define(
this.model = new Map();
}
- if(this.model.get('toolbarOpen') === true) {
+ if (this.model.get('toolbarOpen') === true) {
this.isOpen = true;
}
-
- // Deep clone the section options so that the original array is not
- // modified
- this.sections = _.map(this.sectionOptions, _.clone);
-
- if (this.model.get("showLayerList") === false) {
- this.sections = this.sections.filter(
- (section) => section.label !== "Layers"
- );
- }
- if (this.model.get("showHomeButton") === false) {
- this.sections = this.sections.filter(
- (section) => section.label !== "Home"
- );
- }
- if (!this.model.get("showNavHelp") && !this.model.get("showFeedback")) {
- this.sections = this.sections.filter(
- (section) => section.label !== "Help"
- );
- }
+ // Check whether each section should be shown, defaulting to true.
+ this.sections = this.sectionOptions.filter(section => {
+ return typeof section.isVisible === 'function'
+ ? section.isVisible(this.model)
+ : true;
+ });
} catch (e) {
console.log('Error initializing a ToolbarView', e);
}
@@ -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.
*/
/**
@@ -451,7 +467,8 @@ define(
* specified view in that container.
* @param {SectionOption} sectionOption The view and view options that are set in
* the Section Option are used to create the content container
- * @returns {HTMLElement} Returns the content container with the rendered view
+ * @returns {HTMLElement, Backbone.View} Returns the content container with the rendered view,
+ * and the Backbone.View itself.
*/
renderSectionContent: function (sectionOption) {
try {
@@ -481,7 +498,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 +544,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 +559,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 000000000..15b1151e9
--- /dev/null
+++ b/src/js/views/maps/ViewfinderView.js
@@ -0,0 +1,184 @@
+"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 */ });
+ },
+
+ /**
+ * 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 };
+ },
+
+ /** 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
diff --git a/test/config/tests.json b/test/config/tests.json
index 1fb5afcfa..b70629ad6 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/shared/clean-state.js b/test/js/specs/shared/clean-state.js
new file mode 100644
index 000000000..6fb0ca400
--- /dev/null
+++ b/test/js/specs/shared/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/shared/create-spy.js b/test/js/specs/shared/create-spy.js
new file mode 100644
index 000000000..adb3a4f74
--- /dev/null
+++ b/test/js/specs/shared/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 000000000..83ffd6ba3
--- /dev/null
+++ b/test/js/specs/unit/views/maps/ViewfinderView.spec.js
@@ -0,0 +1,199 @@
+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();
+
+ expect(state.spy.callCount).to.equal(1);
+ });
+
+ 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.clickSearch();
+
+ expect(state.spy.callCount).to.equal(1);
+ });
+
+ describe("good search queries", () => {
+ it("uses the user's search query when zooming", () => {
+ state.view.render();
+
+ 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 });
+ });
+
+ it("accepts user input of two space-separated numbers", () => {
+ state.view.render();
+
+ 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 });
+ });
+
+ it("accepts user input of with '-' signs", () => {
+ state.view.render();
+
+ 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 });
+ });
+
+ it("accepts user input of with '+' signs", () => {
+ state.view.render();
+
+ 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 });
+ });
+ });
+
+ describe("bad search queries", () => {
+ it("shows an error when only a single number is entered", () => {
+ state.view.render();
+
+ state.harness.typeQuery("13")
+ state.harness.clickSearch();
+
+ expect(state.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();
+
+ state.harness.typeQuery("13")
+ state.harness.clickSearch();
+
+ expect(state.spy.callCount).to.equal(0);
+ });
+
+ it("shows an error when non-numeric characters are entered", () => {
+ 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");
+ });
+
+ it("does not try to zoom to location when non-numeric characters are entered", () => {
+ state.view.render();
+
+ state.harness.typeQuery("13,37a")
+ state.harness.clickSearch();
+
+ expect(state.spy.callCount).to.equal(0);
+ });
+
+ it("shows an error when out of bounds latitude is entered", () => {
+ state.view.render();
+
+ state.harness.typeQuery("91,37")
+ state.harness.clickSearch();
+
+ expect(state.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();
+
+ state.harness.typeQuery("91,37")
+ state.harness.clickSearch();
+
+ expect(state.spy.callCount).to.equal(1);
+ });
+
+ it("shows an error when out of bounds longitude is entered", () => {
+ state.view.render();
+
+ state.harness.typeQuery("13,181")
+ state.harness.clickSearch();
+
+ expect(state.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();
+
+ state.harness.typeQuery("13,181")
+ state.harness.clickSearch();
+
+ expect(state.spy.callCount).to.equal(1);
+ });
+
+ it("clears errors 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();
+
+ expect(state.harness.hasError()).to.be.false;
+ });
+
+ 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();
+
+ 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 000000000..2114aee52
--- /dev/null
+++ b/test/js/specs/unit/views/maps/ViewfinderViewHarness.js
@@ -0,0 +1,39 @@
+"use strict";
+
+define([], function () {
+ return class ViewFinderViewHarness {
+ constructor(view) {
+ this.view = view;
+ }
+
+ setQuery(searchString) {
+ this.view.getInput().val(searchString);
+ this.view.getInput().trigger("change");
+ }
+
+ typeQuery(searchString) {
+ this.setQuery(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(".viewfinder__error").text();
+ }
+
+ getInput() {
+ return this.view.getInput();
+ }
+
+ hasError() {
+ return this.getError() !== ''
+ }
+ }
+});