summaryrefslogtreecommitdiff
path: root/horizon/static/framework/widgets/magic-search/magic-search.controller.js
blob: 12901e5ce823e7b888ec1128100c08d661d5dd78 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
/*
 *    (c) Copyright 2015 Hewlett-Packard Development Company, L.P.
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *    http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

(function() {
  'use strict';
/**
 * @fileOverview Magic Search JS
 * @requires AngularJS
 *
 */

  angular.module('horizon.framework.widgets.magic-search')
    .controller('MagicSearchController', magicSearchController);

  magicSearchController.$inject = [
    '$scope', '$element', '$timeout', '$window',
    'horizon.framework.widgets.magic-search.service',
    'horizon.framework.widgets.magic-search.events'
  ];

  function magicSearchController(
    $scope,
    $element,
    $timeout,
    $window,
    service,
    magicSearchEvents
  ) {
    var ctrl = this;
    var searchInput = $element.find('.search-input');
    ctrl.mainPromptString = $scope.strings.prompt;

    // currentSearch is the list of facets representing the current search
    ctrl.currentSearch = [];
    ctrl.isMenuOpen = false;

    searchInput.on('keydown', keyDownHandler);
    searchInput.on('keyup', keyUpHandler);
    searchInput.on('keypress', keyPressHandler);

    // enable text entry when mouse clicked anywhere in search box
    $element.find('.search-main-area').on('click', searchMainClickHandler);

    // when facet clicked, add 1st part of facet and set up options
    ctrl.facetClicked = facetClickHandler;

    // when option clicked, complete facet and send event
    ctrl.optionClicked = optionClickHandler;

    // remove facet and either update filter or search
    ctrl.removeFacet = removeFacet;

    // Controller-exposed Functions
    // clear entire searchbar
    ctrl.clearSearch = clearSearch;

    // ctrl.textSearch is undefined, only used when a user free-enters text

    // Used by the template.
    ctrl.isMatchLabel = function(label) {
      return angular.isArray(label);
    };

    // unusedFacetChoices is the list of facet types that have not been selected
    ctrl.unusedFacetChoices = [];

    // facetChoices is the list of all facet choices
    ctrl.facetChoices = [];

    initSearch(service.getSearchTermsFromQueryString($window.location.search));
    emitQuery();

    $scope.$on(magicSearchEvents.INIT_SEARCH, function(event, data) {
      if ( data ) {
        if ( data.textSearch ) {
          // the requested text search will show up as a 'search in results' facet
          ctrl.textSearch = data.textSearch;
        } else {
          // no requested text search, clear any prior text search
          ctrl.textSearch = undefined;
          searchInput.val('');
        }
        initSearch(data.magicSearchQuery || []);
      }
    });

    function initSearch(initialSearchTerms) {
      // Initializes both the unused choices and the full list of facets
      ctrl.facetChoices = service.getFacetChoicesFromFacetsParam($scope.facets_param);

      // resets the facets
      initFacets(initialSearchTerms);
    }

    function keyDownHandler($event) {
      var key = service.getEventCode($event);
      if (key === 9) {  // prevent default when we can.
        $event.preventDefault();
      } else if (key === 8) {
        backspaceKeyDown();
      }
    }

    function tabKeyUp() {
      if (angular.isUndefined(ctrl.facetSelected)) {
        if (ctrl.filteredObj.length !== 1) {
          return;
        }
        ctrl.facetClicked(0, '', ctrl.filteredObj[0].name);
      } else {
        if (angular.isUndefined(ctrl.filteredOptions) ||
          ctrl.filteredOptions.length !== 1) {
          return;
        }
        ctrl.optionClicked(0, '', ctrl.filteredOptions[0].key);
        resetState();
      }
    }

    function escapeKeyUp() {
      if (angular.isDefined(ctrl.facetSelected)) {
        setMenuOpen(true);
      } else {
        setMenuOpen(false);
      }
      resetState();
      var textFilter = ctrl.textSearch;
      if (angular.isUndefined(textFilter)) {
        textFilter = '';
      }
      emitTextSearch(textFilter);
    }

    function enterKeyUp() {
      var searchVal = searchInput.val();
      // if tag search, treat as regular facet
      if (searchVal !== '') {
        if (ctrl.facetSelected) {
          var curr = ctrl.facetSelected;
          curr.name = curr.name.split('=')[0] + '=' + searchVal;
          curr.label[1] = searchVal;
          ctrl.currentSearch.push(curr);
          resetState();
          emitQuery();
          setMenuOpen(true);
        } else {
          // if text search treat as search
          ctrl.currentSearch = ctrl.currentSearch.filter(notTextSearch);
          ctrl.currentSearch.push(service.getTextFacet(searchVal, $scope.strings.text));
          $scope.$apply();
          setMenuOpen(true);
          setSearchInput('');
          emitTextSearch(searchVal);
          ctrl.textSearch = searchVal;
        }
      } else if (ctrl.isMenuOpen) {
        setMenuOpen(false);
      } else {
        setMenuOpen(true);
      }
      ctrl.filteredObj = ctrl.unusedFacetChoices;
    }

    function backspaceKeyDown() {
      var searchVal = searchInput.val();
      if (searchVal === '') {
        if (ctrl.currentSearch.length > 0 && angular.isUndefined(ctrl.facetSelected)) {
          ctrl.removeFacet(ctrl.currentSearch.length - 1);
          setMenuOpen(true);
        } else {
          escapeKeyUp();
        }
      }
    }

    function backspaceKeyUp() {
      var searchVal = searchInput.val();
      // if there's no current search and facet selected, then clear all search
      if (searchVal === '' && angular.isUndefined(ctrl.facetSelected)) {
        if (ctrl.currentSearch.length === 0) {
          ctrl.clearSearch();
        } else {
          resetState();
          emitTextSearch(ctrl.textSearch || '');
        }
      } else {
        filterFacets(searchVal);
      }
    }

    function deleteKeyUp() {
      return backspaceKeyUp();
    }

    function notTextSearch(item) {
      return item.name.indexOf('text') !== 0;
    }

    function defaultKeyUp() {
      var searchVal = searchInput.val();
      filterFacets(searchVal);
    }

    function keyUpHandler($event) {  // handle ctrl-char input
      if ($event.metaKey === true) {
        return;
      }
      var key = service.getEventCode($event);
      var handlers = {
        8: backspaceKeyUp,
        9: tabKeyUp,
        27: escapeKeyUp,
        13: enterKeyUp,
        46: deleteKeyUp
      };
      if (handlers[key]) {
        handlers[key]();
      } else {
        defaultKeyUp();
      }
    }

    function keyPressHandler($event) {  // handle character input
      var searchVal = searchInput.val();
      var key = service.getEventCode($event);
      // Backspace, Delete, Enter, Tab, Escape
      if (key !== 8 && key !== 46 && key !== 13 && key !== 9 && key !== 27) {
        // This builds the search term as you go.
        searchVal = searchVal + String.fromCharCode(key).toLowerCase();
      }
      if (searchVal === ' ') {  // space and field is empty, show menu
        setMenuOpen(true);
        setSearchInput('');
        return;
      }
      if (searchVal === '') {
        return;
      }
      // Backspace, Delete and arrow keys
      if (key !== 8 && key !== 46 && !(key >= 37 && key <= 40)) {
        filterFacets(searchVal);
      }
    }

    function filterFacets(searchVal) {
      // try filtering facets/options.. if no facets match, do text search
      var filtered = [];
      var isTextSearch = angular.isUndefined(ctrl.facetSelected);
      if (isTextSearch) {
        ctrl.filteredObj = ctrl.unusedFacetChoices;
        filtered = service.getMatchingFacets(ctrl.filteredObj, searchVal);
      } else {  // assume option search
        ctrl.filteredOptions = ctrl.facetOptions;
        if (angular.isUndefined(ctrl.facetOptions)) {
          // no options, assume free form text facet
          return;
        }
        filtered = service.getMatchingOptions(ctrl.filteredOptions, searchVal);
      }
      if (filtered.length > 0) {
        setMenuOpen(true);
        $timeout(function() {
          ctrl.filteredObj = filtered;
        }, 0.1);
      } else if (isTextSearch) {
        emitTextSearch(searchVal);
        setMenuOpen(false);
      }
    }

    function searchMainClickHandler($event) {
      var target = angular.element($event.target);
      if (target.is('.search-main-area')) {
        searchInput.trigger('focus');
        setMenuOpen(true);
      }
    }

    function facetClickHandler($index) {
      var facet = ctrl.filteredObj[$index];
      var label = facet.label;
      if (angular.isArray(label)) {
        label = label.join('');
      }
      var facetParts = facet.name && facet.name.split('=');
      ctrl.facetSelected = service.getFacet(facetParts[0], facetParts[1], label, '');
      if (angular.isDefined(facet.options)) {
        ctrl.filteredOptions = ctrl.facetOptions = facet.options;
        setMenuOpen(true);
      } else {
        setMenuOpen(false);
      }
      setSearchInput('');
      setPrompt('');
      $timeout(function() {
        searchInput.focus();
      });
    }

    function optionClickHandler($index, $event, name) {
      setMenuOpen(false);
      var curr = ctrl.facetSelected;
      curr.name = curr.name.split('=')[0] + '=' + name;
      curr.label[1] = ctrl.filteredOptions[$index].label;
      if (angular.isArray(curr.label[1])) {
        curr.label[1] = curr.label[1].join('');
      }
      ctrl.currentSearch.push(curr);
      resetState();
      emitQuery();
    }

    function emitTextSearch(val) {
      $scope.$emit(magicSearchEvents.TEXT_SEARCH, val, $scope.filter_keys);
    }

    function emitQuery(removed) {
      var query = service.getQueryPattern(ctrl.currentSearch);
      if (angular.isDefined(removed) && removed.indexOf('text') === 0) {
        emitTextSearch('');
        delete ctrl.textSearch;
      } else {
        $scope.$emit(magicSearchEvents.SEARCH_UPDATED, query);
        if (angular.isDefined(ctrl.textSearch)) {
          // emit text search if text facet remains
          emitTextSearch(ctrl.textSearch || '');
        }
        if (ctrl.currentSearch.length > 0) {
          // prune facets as needed from menus
          var newFacet = ctrl.currentSearch[ctrl.currentSearch.length - 1].name;
          var facetParts = service.getSearchTermObject(newFacet);
          service.removeChoice(facetParts, ctrl.facetChoices, ctrl.unusedFacetChoices);
        }
      }
    }

    function clearSearch() {
      ctrl.currentSearch = [];
      delete ctrl.textSearch;
      ctrl.unusedFacetChoices = ctrl.facetChoices.map(service.getFacetChoice);
      resetState();
      $scope.$emit(magicSearchEvents.SEARCH_UPDATED, '');
      emitTextSearch('');
    }

    function resetState() {
      setSearchInput('');
      ctrl.filteredObj = ctrl.unusedFacetChoices;
      delete ctrl.facetSelected;
      delete ctrl.facetOptions;
      delete ctrl.filteredOptions;
      if (ctrl.currentSearch.length === 0) {
        setPrompt(ctrl.mainPromptString);
      }
    }

    function setMenuOpen(bool) {
      $timeout(function setMenuOpenTimeout() {
        ctrl.isMenuOpen = bool;
      });
    }

    function setSearchInput(val) {
      $timeout(function setSearchInputTimeout() {
        searchInput.val(val);
      });
    }

    function setPrompt(str) {
      $timeout(function setPromptTimeout() {
        $scope.strings.prompt = str;
      });
    }

    /**
     * Add ability to update facet
     * Broadcast event when facet options are returned via AJAX.
     * Should magic_search.js absorb this?
     */
    var facetsChangedWatcher = $scope.$on(magicSearchEvents.FACETS_CHANGED, function (event, data) {
      $timeout(function () {
        if (data && data.magicSearchQuery) {
          initSearch(data.magicSearchQuery.split('&'));
        } else {
          initSearch(ctrl.currentSearch.map(function(x) { return x.name; }));
        }
      });
    });

    $scope.$on('$destroy', function () {
      facetsChangedWatcher();
    });

    function initFacets(searchTerms) {
      var tmpFacetChoices = ctrl.facetChoices.map(service.getFacetChoice);
      if (searchTerms.length > 1 || searchTerms[0] && searchTerms[0].length > 0) {
        setPrompt('');
      }
      ctrl.currentSearch = service.getFacetsFromSearchTerms(searchTerms,
        ctrl.textSearch, $scope.strings.text, tmpFacetChoices);
      ctrl.filteredObj = ctrl.unusedFacetChoices =
        service.getUnusedFacetChoices(tmpFacetChoices, searchTerms);

      // emit to check facets for server-side
      $scope.$emit(magicSearchEvents.CHECK_FACETS, ctrl.currentSearch);
    }

    /**
     * Override magic_search.js 'removeFacet' to emit(magicSearchEvents.CHECK_FACETS)
     * to flag facets as 'isServer' after removing facet and
     * either update filter or search
     * @param {number} index - the index of the facet to remove. Required.
     *
     * @returns {number} Doesn't return anything
     */
    function removeFacet(index) {
      var removed = ctrl.currentSearch[index].name;
      ctrl.currentSearch.splice(index, 1);
      if (angular.isUndefined(ctrl.facetSelected)) {
        emitQuery(removed);
      } else {
        resetState();
      }
      if (ctrl.currentSearch.length === 0) {
        setPrompt(ctrl.mainPromptString);
      }
      // re-init to restore facets cleanly
      initFacets(ctrl.currentSearch.map(service.getName));
    }

  }

})();