From 8b2aa8c2f99af5bcf686ac7f3823d06df323c572 Mon Sep 17 00:00:00 2001 From: Thomas Gelf Date: Thu, 17 Nov 2016 20:31:58 +0100 Subject: [PATCH 1/2] js,css: introduce autosuggestion support --- public/css/module.less | 32 +++- public/js/module.js | 366 ++++++++++++++++++++++++++++++++++++++++- 2 files changed, 394 insertions(+), 4 deletions(-) diff --git a/public/css/module.less b/public/css/module.less index 311678c1..38f65078 100644 --- a/public/css/module.less +++ b/public/css/module.less @@ -1152,7 +1152,7 @@ ul.filter-root { padding-left: 0.5em; list-style-type: none; - ul { + ul.filter { padding-left: 1.5em; list-style-type: none; width: 100%; @@ -1251,9 +1251,37 @@ div.filter-expression { } } +ul.director-suggestions { + width: 20em; + max-width: 30em; + max-height: 25em; + overflow-y: auto; + overflow-x: hidden; + border: 1px solid @gray-light; + position: absolute; + z-index: 2000; + padding: 0; + margin: 0; + list-style-type: none; + background-color: rgba(255, 255, 255, 100); + li { + margin: 0; + padding: 0.5em 1em; + } + li:hover { + background-color: @gray-lighter; + cursor: pointer; + } - + li.active { + background-color: @icinga-blue; + color: white; + &:hover { + color: inherit; + } + } +} .tree li a { display: inline-block; diff --git a/public/js/module.js b/public/js/module.js index 677d721e..f07f1e90 100644 --- a/public/js/module.js +++ b/public/js/module.js @@ -26,6 +26,340 @@ this.module.on('click', 'input.related-action', this.extensibleSetAction); this.module.on('focus', 'form input, form textarea, form select', this.formElementFocus); this.module.icinga.logger.debug('Director module initialized'); + this.module.on('keyup', '.director-suggest', this.autoSuggest); + this.module.on('keydown', '.director-suggest', this.suggestionKeyDown); + this.module.on('dblclick', '.director-suggest', this.suggestionDoubleClick); + this.module.on('focus', '.director-suggest', this.enterSuggestionField); + this.module.on('focusout', '.director-suggest', this.leaveSuggestionField); + this.module.on('click', '.director-suggestions li', this.clickSuggestion); + this.module.on('change', 'form input.autosubmit, form select.autosubmit', this.setAutoSubmitted); + }, + + /** + * Autocomplete/suggestion eventhandler + * + * Triggered when pressing a key in a form element with suggestions + * + * @param ev + */ + suggestionKeyDown: function(ev) { + var $suggestions, $active; + var $el = $(ev.currentTarget); + + if (ev.keyCode === 13) { + /** + * RETURN key pressed. In case there are any suggestions: + * - let's choose the active one (if set) + * - stop the event + * + * This let's return bubble up in case there is no suggestion list shown + */ + if (this.hasSuggestions($el)) { + this.chooseActiveSuggestion($el); + ev.stopPropagation(); + ev.preventDefault(); + } else { + this.removeSuggestionList($el); + $el.trigger('change'); + } + } else if (ev.keyCode == 27) { + // ESC key pressed. Remove suggestions if any + this.removeSuggestionList($el); + } else if (ev.keyCode == 39) { + /** + * RIGHT ARROW key pressed. In case there are any suggestions: + * - let's choose the active one (if set) + * - stop the event only if an element has been chosen + * + * This allows to use the right arrow key normally in all other situations + */ + if (this.hasSuggestions($el)) { + if (this.chooseActiveSuggestion($el)) { + ev.stopPropagation(); + ev.preventDefault(); + } + } + } else if (ev.keyCode == 38 ) { + /** + * UP ARROW key pressed. In any case: + * - stop the event + * - activate the previous suggestion if any + */ + ev.stopPropagation(); + ev.preventDefault(); + this.activatePrevSuggestion($el); + } else if (ev.keyCode == 40 ) { // down + /** + * DOWN ARROW key pressed. In any case: + * - stop the event + * - activate the next suggestion if any + */ + ev.stopPropagation(); + ev.preventDefault(); + this.activateNextSuggestion($el); + } + }, + + suggestionDoubleClick: function (ev) + { + var $el = $(ev.currentTarget); + this.getSuggestionList($el) + }, + + /** + * Autocomplete/suggestion eventhandler + * + * Triggered when releasing a key in a form element with suggestions + * + * @param ev + */ + autoSuggest: function(ev) + { + // Ignore special keys, most of them have already been handled on 'keydown' + if (ev.keyCode == 9 || // TAB + ev.keyCode == 13 || // RETURN + ev.keyCode == 27 || // ESC + ev.keyCode == 37 || // LEFT ARROW + ev.keyCode == 38 || // UP ARROW + ev.keyCode == 39 ) { // RIGHT ARROW + return; + } + + var $el = $(ev.currentTarget); + if (ev.keyCode == 40) { // DOWN ARROW + this.getSuggestionList($el); + } else { + this.getSuggestionList($el, true); + } + }, + + /** + * Activate the next related suggestion if any + * + * This walks down the suggestion list, takes care about scrolling and restarts from + * top once reached the bottom + * + * @param $el + */ + activateNextSuggestion: function($el) + { + var $list = this.getSuggestionList($el); + var $next; + var $active = $list.find('li.active'); + if ($active.length) { + $next = $active.next('li'); + if ($next.length === 0) { + $next = $list.find('li').first(); + } + } else { + $next = $list.find('li').first(); + } + if ($next.length) { + // Will not happen when list is empty or last element is active + $list.find('li.active').removeClass('active'); + $next.addClass('active'); + $list.scrollTop($next.offset().top - $list.offset().top - 64 + $list.scrollTop()); + } + }, + + /** + * Activate the previous related suggestion if any + * + * This walks up through the suggestion list and takes care about scrolling. + * Puts the focus back on the input field once reached the top and restarts + * from bottom when moving up from there + * + * @param $el + */ + activatePrevSuggestion: function($el) + { + var $list = this.getSuggestionList($el); + var $prev; + var $active = $list.find('li.active'); + if ($active.length) { + $prev = $active.prev('li'); + } else { + $prev = $list.find('li').last(); + } + $list.find('li.active').removeClass('active'); + + if ($prev.length) { + $prev.addClass('active'); + $list.scrollTop($prev.offset().top - $list.offset().top - 64 + $list.scrollTop()); + } else { + $el.focus(); + } + }, + + /** + * Whether a related suggestion list element exists + * + * @param $input + * @returns {boolean} + */ + hasSuggestionList: function($input) { + var $ul = $input.siblings('ul.director-suggestions'); + return $ul.length > 0; + }, + + /** + * Whether any related suggestions are currently being shown + * + * @param $input + * @returns {boolean} + */ + hasSuggestions: function($input) { + var $ul = $input.siblings('ul.director-suggestions'); + return $ul.length > 0 && $ul.is(':visible'); + }, + + /** + * Get a suggestion list. Optionally force refresh + * + * @param $input + * @param $forceRefresh + * + * @returns {jQuery} + */ + getSuggestionList: function($input, $forceRefresh) + { + var $ul = $input.siblings('ul.director-suggestions'); + if ($ul.length) { + if ($forceRefresh) { + return this.refreshSuggestionList($ul, $input); + } else { + return $ul; + } + } else { + $ul = $(''); + $ul.insertAfter($input); + return this.refreshSuggestionList($ul, $input); + } + }, + + /** + * Refresh a given suggestion list + * + * @param $suggestions + * + * @param $el + * @returns {jQuery} + */ + refreshSuggestionList: function($suggestions, $el) + { + $suggestions.load(icinga.config.baseUrl + '/director/suggest', { + value: $el.val(), + context: $el.data('suggestion-context') + }, function (responseText, textStatus, jqXHR) { + var $li = $suggestions.find('li'); + if ($li.length) { + $suggestions.show(); + } else { + $suggestions.hide(); + } + }); + + return $suggestions; + }, + + /** + * Click handler for proposed suggestions + * + * @param ev + */ + clickSuggestion: function(ev) { + this.chooseSuggestion($(ev.currentTarget)); + }, + + /** + * Choose a specific suggestion + + * @param $suggestion + */ + chooseSuggestion: function($suggestion) + { + var $el = $suggestion.closest('ul').siblings('.director-suggest'); + var val = $suggestion.text(); + var $list = + $el.val(val); + + if (val.match(/\.$/)) { + this.getSuggestionList($el, true); + } else { + $el.focus(); + $el.trigger('change'); + this.getSuggestionList($el).remove(); + } + }, + + /** + * Choose the current active suggestion related to a given element + * + * Returns true in case there was any, false otherwise + * + * @param $el + * @returns {boolean} + */ + chooseActiveSuggestion: function($el) + { + var $list = this.getSuggestionList($el); + var $active = $list.find('li.active'); + if ($active.length) { + this.chooseSuggestion($active); + return true; + } else { + $list.remove(); + return false; + } + }, + + /** + * Remove related suggestion list if any + * + * @param $el + */ + removeSuggestionList: function($el) + { + if (this.hasSuggestionList($el)) { + this.getSuggestionList($el).remove(); + } + }, + + /** + * Show suggestions when arriving to an empte autocompletion field + * + * @param ev + */ + enterSuggestionField: function(ev) { + var $el = $(ev.currentTarget); + if ($el.val() === '' || $el.val().match(/\.$/)) { + this.getSuggestionList($el) + } + }, + + /** + * Close suggestions when leaving the related form element + * + * @param ev + */ + leaveSuggestionField: function(ev) { + return; + var _this = this; + setTimeout(function() { + _this.removeSuggestionList($(ev.currentTarget)); + }, 100); + }, + + /** + * Sets an autosubmit flag on the container related to an event + * + * This will be used in beforeRender to determine whether the request has been triggered by an + * auto-submission + * + * @param ev + */ + setAutoSubmitted: function(ev) { + $(ev.currentTarget).closest('.container').data('directorAutosubmit', 'yes'); }, /** @@ -240,7 +574,6 @@ beforeRender: function(ev) { var $container = $(ev.currentTarget); - var id = $container.attr('id'); var requests = this.module.icinga.loader.requests; if (typeof requests[id] !== 'undefined' && requests[id].autorefresh) { @@ -248,8 +581,34 @@ } else { $container.removeData('director-autorefreshed'); } + + // Remove the temporary directorAutosubmit flag and set or remove + // the directorAutosubmitted property accordingly + if ($container.data('directorAutosubmit') === 'yes') { + $container.removeData('directorAutosubmit'); + $container.data('directorAutosubmitted', 'yes'); + } else { + $container.removeData('directorAutosubmitted'); + } }, + /** + * Whether the given container has been autosubmitted + * + * @param $container + * @returns {boolean} + */ + containerIsAutoSubmitted: function($container) + { + return $container.data('directorAutosubmitted') === 'yes'; + }, + + /** + * Whether the given container has been autorefreshed + * + * @param $container + * @returns {boolean} + */ containerIsAutorefreshed: function($container) { return $container.data('director-autorefreshed') === 'yes'; @@ -271,7 +630,7 @@ // Disabled for now // this.alignDetailLinks(); - if (! this.containerIsAutorefreshed($container)) { + if (! this.containerIsAutorefreshed($container) && ! this.containerIsAutoSubmitted($container)) { this.putFocusOnFirstFormElement($container); } }, @@ -326,6 +685,9 @@ $('fieldset', $form).each(function(idx, fieldset) { var $fieldset = $(fieldset); + if ($fieldset.attr('id') === 'fieldset-assign') { + return; + } if ($fieldset.find('.required').length == 0 && (! self.fieldsetWasOpened($fieldset))) { $fieldset.addClass('collapsed'); self.fixFieldsetInfo($fieldset); From 040a6584772b60c7e906180cdb0bc1549dc94025 Mon Sep 17 00:00:00 2001 From: Thomas Gelf Date: Thu, 17 Nov 2016 20:36:59 +0100 Subject: [PATCH 2/2] suggestions: first test implementation to play with --- application/controllers/SuggestController.php | 122 ++++++++++++++++++ application/views/scripts/suggest/index.phtml | 3 + 2 files changed, 125 insertions(+) create mode 100644 application/controllers/SuggestController.php create mode 100644 application/views/scripts/suggest/index.phtml diff --git a/application/controllers/SuggestController.php b/application/controllers/SuggestController.php new file mode 100644 index 00000000..cf264d0f --- /dev/null +++ b/application/controllers/SuggestController.php @@ -0,0 +1,122 @@ +getRequest()->getPost('context'); + $func = 'suggest' . ucfirst($context); + if (method_exists($this, $func)) { + $all = $this->$func(); + } else { + $all = array(); + } + + $search = $this->getRequest()->getPost('value'); + $begins = array(); + $matches = array(); + $begin = Filter::expression('value', '=', $search . '*'); + $middle = Filter::expression('value', '=', '*' . $search . '*'); + $prefixes = array(); + foreach ($all as $str) { + if (false !== ($pos = strrpos($str, '.'))) { + $prefix = substr($str, 0, $pos) . '.'; + $prefixes[$prefix] = $prefix; + } + if (strlen($search)) { + $row = (object) array('value' => $str); + if ($begin->matches($row)) { + $begins[] = $this->highlight($str, $search); + } elseif ($middle->matches($row)) { + $matches[] = $this->highlight($str, $search); + } + } + } + + $containing = array_slice(array_merge($begins, $matches), 0, 30); + $suggestions = $containing; + + if ($func === 'suggestHostFilterColumns' || $func === 'suggestHostaddresses') { + ksort($prefixes); + + if (count($suggestions) < 5) { + $suggestions = array_merge($suggestions, array_keys($prefixes)); + } + } + $this->view->suggestions = $suggestions; + } + + protected function suggestHostnames() + { + $db = $this->db()->getDbAdapter(); + $query = $db->select()->from('icinga_host', 'object_name')->order('object_name'); + return $db->fetchCol($query); + } + + protected function suggestCheckcommandnames() + { + $db = $this->db()->getDbAdapter(); + $query = $db->select()->from('icinga_command', 'object_name')->order('object_name'); + return $db->fetchCol($query); + } + + protected function suggestHostgroupnames() + { + $db = $this->db()->getDbAdapter(); + $query = $db->select()->from('icinga_hostgroup', 'object_name')->order('object_name'); + return $db->fetchCol($query); + } + + protected function suggestHostaddresses() + { + $db = $this->db()->getDbAdapter(); + $query = $db->select()->from('icinga_host', 'address')->order('address'); + return $db->fetchCol($query); + } + + protected function suggestHostFilterColumns() + { + $all = IcingaHost::enumProperties($this->db(), 'host.'); + $all = array_merge( + array_keys($all[$this->translate('Host properties')]), + array_keys($all[$this->translate('Custom variables')]) + ); + natsort($all); + return $all; + } + + protected function suggestServiceFilterColumns() + { + $all = IcingaService::enumProperties($this->db(), 'service.'); + $all = array_merge( + array_keys($all[$this->translate('Service properties')]), + array_keys($all[$this->translate('Host properties')]), + array_keys($all[$this->translate('Host Custom variables')]), + array_keys($all[$this->translate('Custom variables')]) + ); + // natsort($all); + return $all; + } + + protected function highlight($val, $search) + { + $search = $this->view->escape($search); + $val = $this->view->escape($val); + return str_replace($search, '' . $search . '', $val); + } +} diff --git a/application/views/scripts/suggest/index.phtml b/application/views/scripts/suggest/index.phtml new file mode 100644 index 00000000..5f804e1f --- /dev/null +++ b/application/views/scripts/suggest/index.phtml @@ -0,0 +1,3 @@ + +
  • +